Compare commits

..

20 Commits

Author SHA1 Message Date
kingchenc 57e26fb22f release: bump 0.6.6 -> 0.6.7 (#205)
Release 0.6.7 — ships the B12 DeMark indicators (474 total).

Version-string bump only: Cargo workspace + wickra-core dep, Python pyproject, Node package.json (+6 platform packages), both package-lock files, Cargo.lock, and CHANGELOG.
2026-06-08 01:14:23 +02:00
kingchenc 8431b1400c feat: add DeMark deepening (B12, 7 indicators) (#204)
B12 of the family-deepening roadmap — seven Tom DeMark indicators (467 -> 474).

**Candle -> +1/0 qualifier patterns (candlestick macro bindings):**
- **TD Camouflage** — hidden intrabar strength/weakness against the prior close.
- **TD Clop** — two-bar open/close engulfing reversal.
- **TD Clopwin** — the inside-body cousin of TD Clop (compression bar).
- **TD Propulsion** — continuation thrust closing beyond the prior extreme.
- **TD Trap** — inside ("trap") bar followed by a range breakout.

**Hand-bound:**
- **TD D-Wave** — streaming Elliott-style 1-5 / A-C swing-wave counter (candle -> f64, `strength` param).
- **TD Moving Averages** — ST1/ST2 median-price trend ribbon (candle -> struct {st1, st2}).

All seven join the existing **DeMark** family. Patterns follow the house-style
+1/0 candle-pattern convention (neutral 0.0 during warmup). Public binding names
use the family-consistent `TD...` casing.

Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs
counter (474) and CHANGELOG. Verified: core 3874 + doc 427, clippy clean,
node 549, python 903.
2026-06-08 01:12:46 +02:00
kingchenc ed01604a18 release: bump 0.6.5 -> 0.6.6 (#203)
Release 0.6.6 — ships the B11 Pivots & S/R indicators (467 total) plus the bit-exact batch fast paths and benchmark refresh from #202.

Version-string bump only: Cargo workspace + wickra-core dep, Python pyproject, Node package.json (+6 platform packages), both package-lock files, Cargo.lock, and CHANGELOG.
2026-06-08 00:21:34 +02:00
kingchenc 05fe7ffa90 perf: bit-exact batch fast paths + streaming-first benchmark docs (#202)
## Summary
- Dedicated batch fast paths for **EMA, RSI, Bollinger, MACD and ATR** (used by the Python bindings): one allocation filled in a single pass, warmup encoded as `NaN`, no per-element `Option` or input re-validation. Each is **bit-for-bit equal** to replaying `update` — SMA/Bollinger keep the drift-reseed cadence, the EMA-family keep the seed division and `mul_add` recurrences. Adds the `BatchNanExt` extension trait.
- **Cross-library benchmark refresh**: `compare_libraries.py` reports the median across timing rounds (`--rounds` / `--streaming-rounds`), gains `--skip-batch` / `--skip-streaming`, and runs every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` drives the batch fast paths against `kand`.
- **README** benchmark section reordered streaming-first (the order-of-magnitude result), with measured TA-Lib/tulipy/pandas-ta numbers in place of the CI-only placeholders.

## Impact
- Python batch ~2× faster on EMA/RSI/MACD/ATR; streaming path unchanged.
- The `batch == streaming` equivalence stays bit-exact.

## Verification
- `cargo fmt` · `cargo clippy --workspace --all-targets --all-features -- -D warnings` (clean)
- `cargo test --workspace --all-features` — 3782 unit + 420 doc tests pass
- Python `pytest` — streaming-vs-batch, known-values, input-validation, smoke pass

## Notes
- Node/WASM bindings keep their existing batch; the fast paths are Python-only for now.
2026-06-08 00:17:58 +02:00
kingchenc e97c3389fe feat: add Pivots & S/R indicators (B11) (#201)
Adds five support/resistance and pivot indicators, growing the catalog 462 -> 467.

## Indicators
- **CentralPivotRange** (Candle -> struct) — the classic pivot `(H+L+C)/3` flanked by two central levels (TC/BC); range width gauges trending vs balanced days.
- **MurreyMathLines** (Candle -> struct) — T. H. Murrey's eighths grid over a rolling high-low frame; nine levels (0/8 .. 8/8) acting as support/resistance.
- **AndrewsPitchfork** (Candle -> struct) — median line and two parallels projected forward from the last three auto-detected swing pivots (symmetric fractal of half-width `strength`).
- **VolumeWeightedSr** (Candle -> struct) — a band whose edges are the volume-weighted average of recent highs (resistance) and lows (support); falls back to equal weighting when window volume is zero.
- **PivotReversal** (Candle -> f64) — a `+1`/`-1` breakout signal fired on the bar where price closes through the most recently confirmed swing pivot.

## Wiring
Core structs with branch-complete unit tests, Python/Node/WASM bindings, fuzz drives, reference + streaming-vs-batch tests, README + docs counter sync (FAMILIES "Pivots & S/R"), and CHANGELOG entries.

Verified locally: `cargo fmt`, `cargo test -p wickra-core` (3798 lib + 425 doc), `cargo clippy --workspace --all-targets --all-features -D warnings`, `npm run build && npm test` (542), `maturin develop` + `pytest` (891).
2026-06-08 00:13:42 +02:00
kingchenc 4526278fa0 release: bump 0.6.4 -> 0.6.5 (#200)
Version bump for the **v0.6.5** release shipping the **B10 Ehlers / Cycle** family (#199): 452 -> 462 indicators. Bumps workspace + Python/Node/WASM package versions, lockfiles and CHANGELOG. No code changes.
2026-06-07 04:34:32 +02:00
kingchenc 80850c81f7 Add B10 Ehlers / Cycle deepening (10 indicators) (#199)
Deepens the **Ehlers / Cycle (DSP)** family (B10) with ten indicators (452 -> 462):

- **HighpassFilter**, **Reflex**, **Trendflex**, **CorrelationTrendIndicator**, **AdaptiveRsi**, **UniversalOscillator** — scalar (f64) Ehlers filters/oscillators.
- **AdaptiveCci** — efficiency-ratio-adaptive CCI on typical price (Candle input).
- **BandpassFilter**, **EvenBetterSinewave**, **AutocorrelationPeriodogram** — multi-arg scalar (hand-written bindings; the wasm variadic scalar macro covers wasm).

Verified locally: 3755 core lib + 420 doc tests, clippy clean, 537 node tests, 881 pytest, counter 462.
2026-06-07 04:25:16 +02:00
kingchenc 707f29e8e4 release: bump 0.6.3 -> 0.6.4 (#198)
Version bump for the **v0.6.4** release shipping the **B9 Price Statistics** family (#197): 447 -> 452 indicators. Bumps workspace + Python/Node/WASM package versions, lockfiles and CHANGELOG. No code changes.
2026-06-07 03:20:19 +02:00
kingchenc 389200f855 Add B9 Price Statistics deepening (5 indicators) (#197)
Deepens the **Price Statistics** family (B9) with five rolling-statistics indicators (447 -> 452):

- **ShannonEntropy** — Shannon entropy of a binned rolling value distribution.
- **SampleEntropy** — Richman-Moorman sample entropy (regularity/complexity of a window).
- **KendallTau** — Kendall rank correlation (tau-b) over paired observations (pairwise; distinct from Pearson/Spearman).
- **JarqueBera** — Jarque-Bera normality test statistic over a rolling window.
- **RollingMinMaxScaler** — maps the latest value to 0..1 over a rolling window.

All scalar f64 input except KendallTau (pairwise). Multi-arg scalars (Shannon/Sample entropy) use hand-written Python/Node bindings + the variadic wasm macro; KendallTau uses the pair macros. Verified locally: 3668 core lib + 410 doc tests, clippy clean, 527 node tests, 871 pytest, counter 452.
2026-06-07 03:08:53 +02:00
kingchenc 81406e7a1b release: bump 0.6.2 -> 0.6.3 (#196)
Version bump for the **v0.6.3** release shipping the **B8 Volume** family (#195): 440 -> 447 indicators. Bumps workspace + Python/Node/WASM package versions, lockfiles and CHANGELOG. No code changes.
2026-06-07 02:39:49 +02:00
kingchenc c78b84e186 Add B8 Volume family deepening (7 indicators) (#195)
Deepens the **Volume** family (B8) with seven indicators (440 -> 447):

- **VolumeRsi** — Wilder RSI computed on signed volume flow.
- **WilliamsAd** — Williams Accumulation/Distribution cumulative line (distinct from Chaikin A/D).
- **TwiggsMoneyFlow** — true-range volume accumulation with Wilder smoothing (distinct from CMF).
- **TradeVolumeIndex** — tick-direction volume accumulation past a min-tick threshold (distinct from TSV).
- **IntradayIntensity** — volume weighted by close position within the bar range.
- **BetterVolume** — VSA volume-vs-spread effort/result classifier.
- **VolumeWeightedMacd** — MACD computed on VWMA with signal line and histogram (struct output).

("Up/Down Volume Ratio" already ships from A2.) All Candle input; the six scalar stops emit f64, VolumeWeightedMacd a {macd, signal, histogram} struct. Hand-written Python/Node/WASM bindings for the volume signature. Verified locally: 3620 core lib + 405 doc tests, clippy clean, 522 node tests, 865 pytest, counter 447.
2026-06-07 02:30:56 +02:00
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
96 changed files with 23906 additions and 466 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)."
+96
View File
@@ -0,0 +1,96 @@
# Benchmarks
Read these as **relative** speedups on identical input — absolute µs depend on
CPU, memory clock and OS scheduler, not a universal contract. **Streaming is the
headline**: it is where Wickra's design pays off and where the gap is measured in
orders of magnitude, not percent. The batch numbers come second and are shown
honestly — the leanest crates edge Wickra out on the simple recurrences, and that
is a deliberate trade for warmup/NaN semantics, not a ceiling.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
- **Reproduce yourself:**
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
- Python vs Python libs: `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
## 1. Streaming — the structural win
Live trading feeds one tick at a time. Wickra updates every indicator in **O(1)**;
batch-only libraries (TA-Lib, tulipy, finta, pandas-ta) have no incremental API
and must recompute the whole history on every tick. Only `talipp` (Python) and
`ta-rs` / `yata` (Rust) carry real per-tick state. This is the gap the library
was built to expose.
**Python — per-tick latency** (seed 5 000 bars, then feed ticks one at a time):
| Indicator | **★&nbsp;Wickra** | talipp | TA-Lib (recompute) |
|------------------|------------------:|------------------|-----------------------|
| SMA(20) | **0.063 µs ★** | 0.59 µs (9×) | 204 µs (3 300×) |
| EMA(20) | **0.060 µs ★** | 0.72 µs (12×) | 212 µs (3 500×) |
| RSI(14) | **0.065 µs ★** | 1.06 µs (16×) | 230 µs (3 600×) |
| MACD(12, 26, 9) | **0.078 µs ★** | 4.22 µs (54×) | 245 µs (3 100×) |
| Bollinger(20, 2) | **0.088 µs ★** | 5.15 µs (58×) | 229 µs (2 600×) |
Against the only other incremental Python peer Wickra is **958× faster**;
against the recompute-on-every-tick libraries it is **2 60014 000× faster**
(`finta` RSI hits 14 000×). tulipy / pandas-ta land in the same recompute band
as TA-Lib.
**Rust — per-tick latency** (whole 50 000-bar series, lower = faster):
| Indicator | **★&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 | — |
`ta-rs` hands back a bare `f64` from the first tick with no warmup and no
validation; it leads several rows by giving those guarantees up. Against `kand`,
Wickra wins streaming RSI, Bollinger and ATR. `yata` exposes only SMA/EMA as
raw-value methods, so its other rows are omitted rather than faked.
## 2. Batch — competitive, not the headline
Whole series in one call. Here hand-tuned C (`tulipy`, TA-Lib) and the leanest
Rust crate (`kand`) win the simple recurrences — Wickra trades a few µs per pass
for the `None`-warmup, NaN-safety and bit-exact `batch == streaming` guarantees
none of them keep. It still wins several rows outright and beats the rest of the
field everywhere.
**Python** (20 000-bar pass, µs/op, lower = faster):
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta |
|------------------|---------:|-------:|-------:|----------:|
| SMA(20) | 22.7 | **15.4** | 15.9 | 33.7 |
| EMA(20) | 30.8 | **30.3** | 31.1 | 48.8 |
| RSI(14) | 58.9 | 72.5 | **38.5** | 94.8 |
| MACD(12, 26, 9) | 71.7 | 99.1 | **33.5** | 207.6 |
| Bollinger(20, 2) | 84.9 | 65.7 | **32.3** | 336.4 |
| ATR(14) | 52.0 | 79.4 | **31.9** | — |
Wickra beats TA-Lib on RSI, MACD and ATR and the whole Python field on every
row; tulipy's SIMD C stays ahead on the heavier indicators.
**Rust** (50 000-bar pass, µs, lower = faster). Only Wickra and `kand` expose a
batch API; `ta-rs` and `yata` are streaming-only:
| Indicator | **★&nbsp;Wickra** | kand |
|------------------|------------------:|-------:|
| SMA(20) | 53 | **41** |
| EMA(20) | 111 | **71** |
| RSI(14) | **221 ★** | 259 |
| MACD(12, 26, 9) | 533 | **327** |
| Bollinger(20, 2) | **404 ★** | 460 |
| ATR(14) | **122 ★** | 169 |
Run the suite yourself:
```bash
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
pip install -e bindings/python[bench] # Python peers
python -m benchmarks.compare_libraries
```
+99 -1
View File
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.7] - 2026-06-08
- **TD Camouflage** — a DeMark qualifier flagging hidden intrabar strength or weakness against the prior close (`TDCamouflage`).
- **TD Clop** — a DeMark two-bar open/close engulfing reversal where the bar opens beyond and closes back across the prior body (`TDClop`).
- **TD Clopwin** — the inside-body cousin of TD Clop, marking a compression bar whose direction hints at the next move (`TDClopwin`).
- **TD Propulsion** — a DeMark continuation thrust that opens on the trend side and closes beyond the prior bar's extreme (`TDPropulsion`).
- **TD Trap** — an inside ("trap") bar followed by a close beyond its range, triggering a directional breakout signal (`TDTrap`).
- **TD D-Wave** — a streaming Elliott-style swing-wave counter labelling the market's 15 impulse / AC correction sequence (`TDDWave`).
- **TD Moving Averages** — the DeMark ST1 (fast) and ST2 (slow) median-price trend ribbon whose crossover frames the trend (`TDMovingAverage`).
## [0.6.6] - 2026-06-08
- **Pivot Reversal** — a breakout signal when price closes through the most recently confirmed swing pivot (`PIVOT_REVERSAL`).
- **Volume-Weighted Support/Resistance** — a band whose edges are the volume-weighted average of recent highs and lows (`VOLUME_WEIGHTED_SR`).
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
## [0.6.5] - 2026-06-07
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
## [0.6.4] - 2026-06-07
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`).
- **Shannon Entropy** — Shannon entropy of a rolling value distribution over fixed bins (`SHANNONENT`).
- **Rolling Min-Max Scaler** — Rolling min-max scaler mapping the latest value to 0..1 over a rolling window (`ROLLINGMINMAX`).
- **Jarque-Bera** — Jarque-Bera normality test statistic over a rolling window (`JARQUEBERA`).
## [0.6.3] - 2026-06-07
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
## [0.6.2] - 2026-06-07
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
- **NRTR** — NRTR (Nick Rypock Trailing Reverse) — percentage trailing-reverse stop (`NRTR`).
- **ATR Ratchet** — ATR Ratchet — Kaufman per-bar tightening volatility trailing stop (`ATR_RATCHET`).
- **Elder SafeZone** — Elder SafeZone Stop — average noise-penetration trailing stop with directional flip (`ELDER_SAFE_ZONE`).
- **Kase DevStop** — Kase DevStop volatility trailing stop using standard-deviation of two-bar true range (`KASE_DEV_STOP`).
## [0.6.1] - 2026-06-07
- **Projection Oscillator** — Widner projection oscillator: close position inside the projection bands, scaled 0..100 (`ProjectionOscillator`).
- **Projection Bands** — Widner projection bands: forward-projected high/low regression envelope (`ProjectionBands`).
- **Median Channel** — robust median +/- multiplier*MAD envelope (`MedianChannel`).
- **Bomar Bands** — adaptive percentage bands containing a target coverage fraction of recent closes (`BomarBands`).
- **Quartile Bands** — rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope (`QuartileBands`).
## [0.6.0] - 2026-06-06
- **Volatility Cone** — volatility cone: current realized volatility within its historical min/median/max envelope (`VolatilityCone`).
- **VolatilityRatio** — Schwager's volatility ratio: true range over the EMA of prior true ranges (`VolatilityRatio`).
- **BipowerVariation** — jump-robust realized bipower variation (pi/2 sum of adjacent absolute log-return products) (`BipowerVariation`).
- **VolatilityOfVolatility** — vol-of-vol: sample stddev of a rolling realized-volatility series (`VolatilityOfVolatility`).
- **Garch11** — GARCH(1,1) conditional volatility with a long-run-variance anchor (`Garch11`).
- **EwmaVolatility** — RiskMetrics exponentially-weighted volatility of log returns (lambda decay) (`EwmaVolatility`).
## [0.5.9] - 2026-06-06
### Added
- Internal Rust cross-library benchmark harness (`crates/wickra-bench`, not
published) comparing Wickra against `kand`, `ta-rs` and `yata` on an identical
candle series in both streaming and batch modes; wired into the nightly
`cross-library-bench` workflow.
- `tulipy` runners and expanded per-tick streaming coverage (SMA, EMA, RSI,
MACD, Bollinger) in the Python `compare_libraries` benchmark.
### Changed
- Faster streaming and batch updates for SMA, Bollinger Bands, RSI, EMA and ATR
(flat ring buffers replacing `VecDeque`, hoisted reciprocals in the Wilder
smoothing, leaner hot state) — indicator outputs are unchanged.
- Rewrote the README benchmark section into honest, tiered tables (Rust core vs
the other Rust crates, and Python vs the Python ecosystem) that show where
Wickra wins and where it loses, not only the favourable comparisons.
## [0.5.8] - 2026-06-04
- **TSF Oscillator** — the percentage gap of the close to the one-bar-ahead time-series forecast, a close-relative companion to CFO (`TsfOscillator`).
- **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`).
@@ -1273,7 +1362,16 @@ 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.7...HEAD
[0.6.7]: https://github.com/wickra-lib/wickra/compare/v0.6.6...v0.6.7
[0.6.6]: https://github.com/wickra-lib/wickra/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/wickra-lib/wickra/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/wickra-lib/wickra/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/wickra-lib/wickra/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
[0.5.9]: https://github.com/wickra-lib/wickra/compare/v0.5.8...v0.5.9
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
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.7"
dependencies = [
"approx",
"criterion",
@@ -1876,9 +1953,21 @@ dependencies = [
"wickra-data",
]
[[package]]
name = "wickra-bench"
version = "0.6.7"
dependencies = [
"criterion",
"kand",
"ta",
"wickra",
"wickra-data",
"yata",
]
[[package]]
name = "wickra-core"
version = "0.5.8"
version = "0.6.7"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.8"
version = "0.6.7"
dependencies = [
"approx",
"csv",
@@ -1905,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.0.0"
version = "0.6.7"
dependencies = [
"serde_json",
"tokio",
@@ -1915,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.8"
version = "0.6.7"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.8"
version = "0.6.7"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.8"
version = "0.6.7"
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.7"
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.7" }
thiserror = "2"
rayon = "1.10"
+89 -87
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=474" 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 474 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),
@@ -58,85 +58,77 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Why Wickra
Most TA libraries are fast, *or* multi-language, *or* broad. Wickra refuses to
pick. It's the streaming-first engine built for the workload the others treat as
an afterthought — **live, tick-by-tick data** — without giving up the breadth of
a full batch library, and without making you reimplement your indicators four
times to get there.
- **The biggest streaming-native catalogue, period.** 474 indicators across 24
families — candlesticks, harmonic & chart patterns, market profile, market
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
none of them stream.
- **One Rust core, four first-class targets.** Native **Python · Node.js ·
WebAssembly · Rust** — identical math, identical results, zero per-language
reimplementation and zero GIL bottleneck.
- **Correct by construction, not by hope.** Every `update` validates its input,
runs a real warmup, and returns an `Option` so a single bad tick can't silently
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
for all 474 indicators**.
- **Orders of magnitude faster where it counts.** In streaming Wickra is **958×**
faster than the only other incremental peer and **thousands of times** faster
than recompute-on-every-tick libraries. On batch it wins several rows outright
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
the losses are shown, not hidden.
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra`
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
macOS · Linux · Windows.
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
live Binance kline feed ship in the box.
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
and closed-source work.
Every other library forces one of those compromises. Wickra doesn't:
| Library | Install | Streaming | Languages | Indicators | Active |
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
| **★&nbsp;Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **474** | **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 |
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
the same time. That's the combination no one else ships.
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
Wickra started as a personal itch. The existing TA libraries never quite fit the
projects I was building, so I decided to build one from the ground up — partly to
learn, partly because I genuinely enjoy taking something that already exists and
trying to do it differently (and, ideally, better). It's open source because the
useful version of that itch is the one other people can build on too.
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&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 |
## Benchmarks
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
workload it is built for — it is **958× faster** than the only other incremental
peer and **thousands of times** faster than recompute-on-every-tick libraries.
**Batch** is competitive: it wins several rows outright and trades a few µs
elsewhere for `None`-warmup, NaN-safety and bit-exact `batch == streaming`.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&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) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
**[BENCHMARKS.md](BENCHMARKS.md)**.
## Indicators
423 streaming-first indicators across twenty-four families. Every one passes the
474 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,14 +139,14 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level, TD Camouflage, TD Clop, TD Clopwin, TD Propulsion, TD Trap, TD D-Wave, TD Moving Averages |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
@@ -245,9 +237,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 474 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 +254,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 +265,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 +366,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,23 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
UNIVERSALOSC: () => new wickra.UNIVERSALOSC(20),
ADAPTIVERSI: () => new wickra.ADAPTIVERSI(14),
CTI: () => new wickra.CTI(20),
TRENDFLEX: () => new wickra.TRENDFLEX(20),
REFLEX: () => new wickra.REFLEX(20),
HIGHPASS: () => new wickra.HIGHPASS(48),
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
JARQUEBERA: () => new wickra.JARQUEBERA(20),
BipowerVariation: () => new wickra.BipowerVariation(20),
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
TsfOscillator: () => new wickra.TsfOscillator(3),
@@ -350,6 +367,23 @@ const candleScalar = {
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDCamouflage: { make: () => new wickra.TDCamouflage(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDClop: { make: () => new wickra.TDClop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDClopwin: { make: () => new wickra.TDClopwin(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDPropulsion: { make: () => new wickra.TDPropulsion(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDTrap: { make: () => new wickra.TDTrap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDDWave: { make: () => new wickra.TDDWave(2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -436,6 +470,22 @@ const multi = {
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QuartileBands: { make: () => new wickra.QuartileBands(4), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BomarBands: { make: () => new wickra.BomarBands(4, 0.85), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MedianChannel: { make: () => new wickra.MedianChannel(5, 2.0), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
ProjectionBands: { make: () => new wickra.ProjectionBands(3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
KaseDevStop: { make: () => new wickra.KaseDevStop(3, 1.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ElderSafeZone: { make: () => new wickra.ElderSafeZone(14, 2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
TDMovingAverage: { make: () => new wickra.TDMovingAverage(5, 13), fields: ['st1', 'st2'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
};
for (const [name, d] of Object.entries(multi)) {
@@ -605,6 +655,7 @@ const pairFactories = {
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
KendallTau: () => new wickra.KendallTau(20),
};
for (const [name, make] of Object.entries(pairFactories)) {
+572
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,51 @@ 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 CentralPivotRangeValue {
pivot: number
tc: number
bc: number
}
export interface MurreyMathLinesValue {
mm8_8: number
mm7_8: number
mm6_8: number
mm5_8: number
mm4_8: number
mm3_8: number
mm2_8: number
mm1_8: number
mm0_8: number
}
export interface AndrewsPitchforkValue {
median: number
upper: number
lower: number
}
export interface VolumeWeightedSrValue {
support: number
resistance: number
}
export interface DoubleBollingerValue {
upperOuter: number
upperInner: number
@@ -266,6 +342,10 @@ export interface TdSequentialValue {
countdown: number
direction: number
}
export interface TdMovingAverageValue {
st1: number
st2: number
}
/** TD Lines output pair: latest TDST resistance / support (NaN if unset). */
export interface TdLinesValue {
resistance: number
@@ -438,6 +518,11 @@ export interface FibTimeZonesValue {
onZone: number
barsToNext: number
}
export interface VolumeWeightedMacdValue {
macd: number
signal: number
histogram: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -987,6 +1072,168 @@ 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 JarqueBeraNode = JARQUEBERA
export declare class JARQUEBERA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RollingMinMaxScalerNode = ROLLINGMINMAX
export declare class ROLLINGMINMAX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HighpassFilterNode = HIGHPASS
export declare class HIGHPASS {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ReflexNode = REFLEX
export declare class REFLEX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TrendflexNode = TRENDFLEX
export declare class TRENDFLEX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CorrelationTrendIndicatorNode = CTI
export declare class CTI {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AdaptiveRsiNode = ADAPTIVERSI
export declare class ADAPTIVERSI {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type UniversalOscillatorNode = UNIVERSALOSC
export declare class UNIVERSALOSC {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BandpassFilterNode = BANDPASS
export declare class BANDPASS {
constructor(period: number, bandwidth: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EvenBetterSinewaveNode = EVENBETTERSINE
export declare class EVENBETTERSINE {
constructor(hpPeriod: number, ssfLength: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AutocorrelationPeriodogramNode = AUTOCORRPGRAM
export declare class AUTOCORRPGRAM {
constructor(minPeriod: number, maxPeriod: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ShannonEntropyNode = SHANNONENT
export declare class SHANNONENT {
constructor(period: number, bins: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SampleEntropyNode = SAMPLEENT
export declare class SAMPLEENT {
constructor(period: number, m: number, rFactor: 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)
@@ -1162,6 +1409,19 @@ export declare class DistanceSsd {
isReady(): boolean
warmupPeriod(): number
}
export type KendallTauNode = KendallTau
export declare class KendallTau {
constructor(period: number)
update(x: number, y: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array
* with `NaN` for warmup positions.
*/
batch(x: Array<number>, y: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BetaNeutralSpreadNode = BetaNeutralSpread
export declare class BetaNeutralSpread {
constructor(period: number)
@@ -1574,6 +1834,42 @@ 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 AdaptiveCciNode = ADAPTIVECCI
export declare class ADAPTIVECCI {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -2234,6 +2530,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 +2923,87 @@ 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 CentralPivotRangeNode = CentralPivotRange
export declare class CentralPivotRange {
constructor()
update(high: number, low: number, close: number): CentralPivotRangeValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MurreyMathLinesNode = MurreyMathLines
export declare class MurreyMathLines {
constructor(period: number)
update(high: number, low: number): MurreyMathLinesValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AndrewsPitchforkNode = AndrewsPitchfork
export declare class AndrewsPitchfork {
constructor(strength: number)
update(high: number, low: number): AndrewsPitchforkValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolumeWeightedSrNode = VolumeWeightedSr
export declare class VolumeWeightedSr {
constructor(period: number)
update(high: number, low: number, volume: number): VolumeWeightedSrValue | null
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type PivotReversalNode = PivotReversal
export declare class PivotReversal {
constructor(left: number, right: 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 DoubleBollingerNode = DoubleBollinger
export declare class DoubleBollinger {
constructor(period: number, kInner: number, kOuter: number)
@@ -2721,6 +3163,24 @@ export declare class TDCombo {
isReady(): boolean
warmupPeriod(): number
}
export type TdDWaveNode = TDDWave
export declare class TDDWave {
constructor(strength: 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 TdMovingAverageNode = TDMovingAverage
export declare class TDMovingAverage {
constructor(periodSt1: number, periodSt2: number)
update(high: number, low: number): TdMovingAverageValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TdCountdownNode = TDCountdown
export declare class TDCountdown {
constructor(setupLookback: number, setupTarget: number, countdownLookback: number, countdownTarget: number)
@@ -3634,6 +4094,51 @@ export declare class ThreeDrives {
isReady(): boolean
warmupPeriod(): number
}
export type TdCamouflageNode = TDCamouflage
export declare class TDCamouflage {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TdClopNode = TDClop
export declare class TDClop {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TdClopwinNode = TDClopwin
export declare class TDClopwin {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TdPropulsionNode = TDPropulsion
export declare class TDPropulsion {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TdTrapNode = TDTrap
export declare class TDTrap {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
export declare class OrderBookImbalanceTop1 {
constructor()
@@ -4416,3 +4921,70 @@ export declare class FibTimeZones {
isReady(): boolean
warmupPeriod(): number
}
export type VolumeRsiNode = VolumeRsi
export declare class VolumeRsi {
constructor(period: number)
update(close: number, volume: number): number | null
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WadNode = Wad
export declare class Wad {
constructor()
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TwiggsMoneyFlowNode = TwiggsMoneyFlow
export declare class TwiggsMoneyFlow {
constructor(period: number)
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TradeVolumeIndexNode = TradeVolumeIndex
export declare class TradeVolumeIndex {
constructor(minTick: number)
update(close: number, volume: number): number | null
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type IntradayIntensityNode = IntradayIntensity
export declare class IntradayIntensity {
constructor()
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BetterVolumeNode = BetterVolume
export declare class BetterVolume {
constructor(period: number)
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolumeWeightedMacdNode = VolumeWeightedMacd
export declare class VolumeWeightedMacd {
constructor(fast: number, slow: number, signal: number)
update(close: number, volume: number): VolumeWeightedMacdValue | null
/**
* Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
* Warmup positions are `NaN`.
*/
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
+52 -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.7",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.8",
"version": "0.6.7",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.8",
"version": "0.6.7",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.8",
"version": "0.6.7",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.8",
"version": "0.6.7",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.8",
"version": "0.6.7",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.8",
"version": "0.6.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.8",
"version": "0.6.7",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.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.7",
"wickra-darwin-x64": "0.6.7",
"wickra-linux-arm64-gnu": "0.6.7",
"wickra-linux-x64-gnu": "0.6.7",
"wickra-win32-arm64-msvc": "0.6.7",
"wickra-win32-x64-msvc": "0.6.7"
}
},
"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.7",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.7.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.7",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.7.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.7",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.7.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.7",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.7.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.7",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.7.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.7",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.7.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.7",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.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.7",
"wickra-linux-arm64-gnu": "0.6.7",
"wickra-darwin-x64": "0.6.7",
"wickra-darwin-arm64": "0.6.7",
"wickra-win32-x64-msvc": "0.6.7",
"wickra-win32-arm64-msvc": "0.6.7"
},
"scripts": {
"build": "napi build --platform --release",
File diff suppressed because it is too large Load Diff
+368 -16
View File
@@ -49,6 +49,7 @@ TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta")
TULIPY = _try_import("tulipy")
PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable
@@ -71,13 +72,23 @@ class Sample:
return (self.seconds / self.iterations) * 1_000_000
def time_call(fn: Callable[[], None], iterations: int) -> float:
"""Time ``fn`` over ``iterations`` calls, returning total wall seconds."""
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
"""Time ``fn`` over ``iterations`` calls per round, across ``rounds`` rounds.
Returns the *median* round's wall seconds for one round of ``iterations``
calls. Taking the median across several rounds damps the OS scheduling and
GC jitter that a single timing pass would otherwise bake into the result,
so the per-iteration figure is stable run-to-run. Callers keep dividing the
return value by ``iterations``.
"""
fn() # one warmup call to populate caches
start = time.perf_counter()
for _ in range(iterations):
fn()
return time.perf_counter() - start
rounds_s: List[float] = []
for _ in range(rounds):
start = time.perf_counter()
for _ in range(iterations):
fn()
rounds_s.append(time.perf_counter() - start)
return statistics.median(rounds_s)
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
@@ -275,6 +286,34 @@ def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices))
# tulipy wraps the C "Tulip Indicators" library; it takes contiguous float64
# arrays and indicator options as positional arguments.
def tulipy_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.sma(prices, 20))
def tulipy_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.ema(prices, 20))
def tulipy_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.rsi(prices, 14))
def tulipy_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.macd(prices, 12, 26, 9))
def tulipy_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.bbands(prices, 20, 2.0))
def tulipy_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.atr(high, low, close, 14))
# --------------------------------------------------------------------------- #
# Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- #
@@ -329,6 +368,260 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl
return run
# Scalar streaming peers: Wickra and talipp both update incrementally in O(1),
# so this is the like-for-like per-tick comparison (batch-only libs are covered
# by the batch tables and the recompute contrast on RSI above).
def wickra_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
sma = WICKRA.SMA(20)
sma.batch(seed)
for p in live:
sma.update(float(p))
return run
def talipp_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import SMA # type: ignore
def run() -> None:
sma = SMA(period=20, input_values=list(seed))
for p in live:
sma.add(float(p))
return run
def wickra_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
ema = WICKRA.EMA(20)
ema.batch(seed)
for p in live:
ema.update(float(p))
return run
def talipp_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
def run() -> None:
ema = EMA(period=20, input_values=list(seed))
for p in live:
ema.add(float(p))
return run
def wickra_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
macd = WICKRA.MACD()
macd.batch(seed)
for p in live:
macd.update(float(p))
return run
def talipp_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
def run() -> None:
macd = MACD(
fast_period=12, slow_period=26, signal_period=9, input_values=list(seed)
)
for p in live:
macd.add(float(p))
return run
def wickra_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
bb = WICKRA.BollingerBands(20, 2.0)
bb.batch(seed)
for p in live:
bb.update(float(p))
return run
def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
def run() -> None:
bb = BB(period=20, std_dev_mult=2.0, input_values=list(seed))
for p in live:
bb.add(float(p))
return run
# Recompute streaming peers: batch-only libraries have no incremental API, so
# the only honest way to drive them tick-by-tick is to re-run the full batch
# over the grown history on every new price. These runners expose exactly that
# cost — the gap Wickra's O(1) update closes.
def _talib_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(np.asarray(history))
return run
def _pandas_ta_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(PD.Series(history))
return run
def _tulipy_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(np.asarray(history, dtype=np.float64))
return run
def _finta_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
arr = np.asarray(history)
fn(PD.DataFrame({"open": arr, "high": arr, "low": arr, "close": arr, "volume": np.ones_like(arr)}))
return run
def talib_sma_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.SMA(a, timeperiod=20))
def pandas_ta_sma_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.sma(s, length=20))
def tulipy_sma_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.sma(a, 20))
def finta_sma_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.SMA(df, period=20))
def talib_ema_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.EMA(a, timeperiod=20))
def pandas_ta_ema_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.ema(s, length=20))
def tulipy_ema_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.ema(a, 20))
def finta_ema_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.EMA(df, period=20))
def tulipy_rsi_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.rsi(a, 14))
def finta_rsi_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.RSI(df, period=14))
def talib_macd_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.MACD(a))
def pandas_ta_macd_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.macd(s))
def tulipy_macd_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.macd(a, 12, 26, 9))
def finta_macd_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.MACD(df))
def talib_bollinger_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.BBANDS(a, timeperiod=20, nbdevup=2, nbdevdn=2))
def pandas_ta_bollinger_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.bbands(s, length=20, std=2.0))
def tulipy_bollinger_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.bbands(a, 20, 2.0))
def finta_bollinger_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0))
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
@@ -339,6 +632,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch),
("tulipy", tulipy_sma_batch),
("finta", finta_sma_batch),
("talipp", talipp_sma_batch),
]),
@@ -346,6 +640,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch),
("tulipy", tulipy_ema_batch),
("finta", finta_ema_batch),
("talipp", talipp_ema_batch),
]),
@@ -353,6 +648,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch),
("tulipy", tulipy_rsi_batch),
("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch),
]),
@@ -360,6 +656,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch),
("tulipy", tulipy_macd_batch),
("finta", finta_macd_batch),
("talipp", talipp_macd_batch),
]),
@@ -367,6 +664,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch),
("tulipy", tulipy_bollinger_batch),
("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch),
]),
@@ -376,29 +674,64 @@ OHLC_INDICATORS = [
("ATR(14)", [
("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch),
("tulipy", tulipy_atr_batch),
("finta", finta_atr_batch),
("talipp", talipp_atr_batch),
]),
]
STREAMING_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_streaming),
("talipp", talipp_sma_streaming),
("TA-Lib", talib_sma_streaming),
("pandas-ta", pandas_ta_sma_streaming),
("tulipy", tulipy_sma_streaming),
("finta", finta_sma_streaming),
]),
("EMA(20)", [
("Wickra", wickra_ema_streaming),
("talipp", talipp_ema_streaming),
("TA-Lib", talib_ema_streaming),
("pandas-ta", pandas_ta_ema_streaming),
("tulipy", tulipy_ema_streaming),
("finta", finta_ema_streaming),
]),
("RSI(14)", [
("Wickra", wickra_rsi_streaming),
("talipp", talipp_rsi_streaming),
("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming),
("talipp", talipp_rsi_streaming),
("tulipy", tulipy_rsi_streaming),
("finta", finta_rsi_streaming),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_streaming),
("talipp", talipp_macd_streaming),
("TA-Lib", talib_macd_streaming),
("pandas-ta", pandas_ta_macd_streaming),
("tulipy", tulipy_macd_streaming),
("finta", finta_macd_streaming),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_streaming),
("talipp", talipp_bollinger_streaming),
("TA-Lib", talib_bollinger_streaming),
("pandas-ta", pandas_ta_bollinger_streaming),
("tulipy", tulipy_bollinger_streaming),
("finta", finta_bollinger_streaming),
]),
]
def run_batch(prices: np.ndarray, iterations: int) -> List[Sample]:
def run_batch(prices: np.ndarray, iterations: int, rounds: int) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in BATCH_INDICATORS:
for lib_name, factory in libs:
runner = factory(prices)
if runner is None:
continue
secs = time_call(runner, iterations)
secs = time_call(runner, iterations, rounds)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
@@ -408,6 +741,7 @@ def run_ohlc(
low: np.ndarray,
close: np.ndarray,
iterations: int,
rounds: int,
) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in OHLC_INDICATORS:
@@ -415,12 +749,12 @@ def run_ohlc(
runner = factory(high, low, close)
if runner is None:
continue
secs = time_call(runner, iterations)
secs = time_call(runner, iterations, rounds)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) -> List[Sample]:
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int, rounds: int) -> List[Sample]:
out: List[Sample] = []
seed = prices[:streaming_window]
live = prices[streaming_window:]
@@ -431,7 +765,7 @@ def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) ->
runner = factory(seed, live)
if runner is None:
continue
secs = time_call(runner, iterations)
secs = time_call(runner, iterations, rounds)
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
sample.iterations = iterations * len(live) # per-tick normalization
out.append(sample)
@@ -479,6 +813,12 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
parser.add_argument(
"--rounds",
type=int,
default=5,
help="batch timing rounds; the median round is reported to damp jitter",
)
parser.add_argument(
"--streaming-window",
type=int,
@@ -491,6 +831,14 @@ def parse_args() -> argparse.Namespace:
default=3,
help="repetitions of the streaming workload (each iteration replays all live ticks)",
)
parser.add_argument(
"--streaming-rounds",
type=int,
default=2,
help="streaming timing rounds; the median round is reported",
)
parser.add_argument("--skip-batch", action="store_true", help="skip the batch tables")
parser.add_argument("--skip-streaming", action="store_true", help="skip the streaming tables")
return parser.parse_args()
@@ -501,6 +849,7 @@ def main() -> None:
available = []
if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta")
if TULIPY is not None: available.append("tulipy")
if FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
@@ -509,11 +858,14 @@ def main() -> None:
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
high, low, close, _ = gen_ohlc(args.size)
batch_rows = run_batch(prices, args.iterations)
ohlc_rows = run_ohlc(high, low, close, args.iterations)
streaming_rows = run_streaming(prices, args.streaming_window, args.streaming_iterations)
rows: List[Sample] = []
if not args.skip_batch:
rows += run_batch(prices, args.iterations, args.rounds)
rows += run_ohlc(high, low, close, args.iterations, args.rounds)
if not args.skip_streaming:
rows += run_streaming(prices, args.streaming_window, args.streaming_iterations, args.streaming_rounds)
print(render_table(batch_rows + ohlc_rows + streaming_rows))
print(render_table(rows))
if __name__ == "__main__":
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.5.8"
version = "0.6.7"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
@@ -39,6 +39,7 @@ bench = [
"pytest-benchmark>=4",
"TA-Lib; platform_system != 'Windows'",
"pandas-ta>=0.3.14b",
"tulipy>=0.4; platform_system != 'Windows'",
"talipp>=2",
"finta>=1.3",
"pandas>=2",
+104
View File
@@ -25,6 +25,28 @@ from __future__ import annotations
from ._wickra import (
__version__,
AUTOCORRPGRAM,
EVENBETTERSINE,
BANDPASS,
ADAPTIVECCI,
UNIVERSALOSC,
ADAPTIVERSI,
CTI,
TRENDFLEX,
REFLEX,
HIGHPASS,
SAMPLEENT,
SHANNONENT,
ROLLINGMINMAX,
JARQUEBERA,
TimeBasedStop,
ProjectionOscillator,
VolatilityCone,
VolatilityRatio,
BipowerVariation,
VolatilityOfVolatility,
Garch11,
EwmaVolatility,
PpoHistogram,
MacdHistogram,
TsfOscillator,
@@ -161,6 +183,11 @@ from ._wickra import (
HistoricalVolatility,
BollingerBandwidth,
PercentB,
# Trailing Stops
ModifiedMaStop,
Nrtr,
AtrRatchet,
ElderSafeZone,
SuperTrend,
ChandelierExit,
ChandeKrollStop,
@@ -172,6 +199,7 @@ from ._wickra import (
PercentageTrailingStop,
StepTrailingStop,
RenkoTrailingStop,
KaseDevStop,
TrueRange,
ChaikinVolatility,
RVIVolatility,
@@ -180,6 +208,13 @@ from ._wickra import (
RogersSatchellVolatility,
YangZhangVolatility,
# Volume
VolumeWeightedMacd,
BetterVolume,
IntradayIntensity,
TradeVolumeIndex,
TwiggsMoneyFlow,
Wad,
VolumeRsi,
OBV,
VWAP,
RollingVWAP,
@@ -200,6 +235,7 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
KendallTau,
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
@@ -257,6 +293,10 @@ from ._wickra import (
MAMA,
FAMA,
# Bands & Channels
ProjectionBands,
MedianChannel,
BomarBands,
QuartileBands,
MaEnvelope,
AccelerationBands,
StarcBands,
@@ -269,6 +309,11 @@ from ._wickra import (
FractalChaosBands,
VwapStdDevBands,
# Pivots & S/R
PivotReversal,
VolumeWeightedSr,
AndrewsPitchfork,
MurreyMathLines,
CentralPivotRange,
ClassicPivots,
FibonacciPivots,
Camarilla,
@@ -277,6 +322,13 @@ from ._wickra import (
WilliamsFractals,
ZigZag,
# DeMark
TDMovingAverage,
TDDWave,
TDTrap,
TDPropulsion,
TDClopwin,
TDClop,
TDCamouflage,
TDSetup,
TDSequential,
TDDeMarker,
@@ -476,6 +528,28 @@ from ._wickra import (
)
__all__ = [
"AUTOCORRPGRAM",
"EVENBETTERSINE",
"BANDPASS",
"ADAPTIVECCI",
"UNIVERSALOSC",
"ADAPTIVERSI",
"CTI",
"TRENDFLEX",
"REFLEX",
"HIGHPASS",
"SAMPLEENT",
"SHANNONENT",
"ROLLINGMINMAX",
"JARQUEBERA",
"TimeBasedStop",
"ProjectionOscillator",
"VolatilityCone",
"VolatilityRatio",
"BipowerVariation",
"VolatilityOfVolatility",
"Garch11",
"EwmaVolatility",
"PpoHistogram",
"MacdHistogram",
"TsfOscillator",
@@ -613,6 +687,11 @@ __all__ = [
"HistoricalVolatility",
"BollingerBandwidth",
"PercentB",
# Trailing Stops
"ModifiedMaStop",
"Nrtr",
"AtrRatchet",
"ElderSafeZone",
"SuperTrend",
"ChandelierExit",
"ChandeKrollStop",
@@ -624,6 +703,7 @@ __all__ = [
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"KaseDevStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
@@ -632,6 +712,13 @@ __all__ = [
"RogersSatchellVolatility",
"YangZhangVolatility",
# Volume
"VolumeWeightedMacd",
"BetterVolume",
"IntradayIntensity",
"TradeVolumeIndex",
"TwiggsMoneyFlow",
"Wad",
"VolumeRsi",
"OBV",
"VWAP",
"RollingVWAP",
@@ -652,6 +739,7 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"KendallTau",
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
@@ -709,6 +797,10 @@ __all__ = [
"MAMA",
"FAMA",
# Bands & Channels
"ProjectionBands",
"MedianChannel",
"BomarBands",
"QuartileBands",
"MaEnvelope",
"AccelerationBands",
"StarcBands",
@@ -721,6 +813,11 @@ __all__ = [
"FractalChaosBands",
"VwapStdDevBands",
# Pivots & S/R
"PivotReversal",
"VolumeWeightedSr",
"AndrewsPitchfork",
"MurreyMathLines",
"CentralPivotRange",
"ClassicPivots",
"FibonacciPivots",
"Camarilla",
@@ -729,6 +826,13 @@ __all__ = [
"WilliamsFractals",
"ZigZag",
# DeMark
"TDMovingAverage",
"TDDWave",
"TDTrap",
"TDPropulsion",
"TDClopwin",
"TDClop",
"TDCamouflage",
"TDSetup",
"TDSequential",
"TDDeMarker",
+3056 -146
View File
File diff suppressed because it is too large Load Diff
+346 -1
View File
@@ -45,6 +45,23 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.AUTOCORRPGRAM, (10, 48)),
(ta.EVENBETTERSINE, (40, 10)),
(ta.BANDPASS, (20, 0.3)),
(ta.UNIVERSALOSC, (20,)),
(ta.ADAPTIVERSI, (14,)),
(ta.CTI, (20,)),
(ta.TRENDFLEX, (20,)),
(ta.REFLEX, (20,)),
(ta.HIGHPASS, (48,)),
(ta.SAMPLEENT, (20, 2, 0.2)),
(ta.SHANNONENT, (20, 8)),
(ta.ROLLINGMINMAX, (20,)),
(ta.JARQUEBERA, (20,)),
(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 +186,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),
@@ -197,6 +217,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.KendallTau, (20,)),
(ta.SpreadAr1Coefficient, (40,)),
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
@@ -361,6 +382,62 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"TDDWave": (
lambda: ta.TDDWave(2),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"TDTrap": (
lambda: ta.TDTrap(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TDPropulsion": (
lambda: ta.TDPropulsion(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TDClopwin": (
lambda: ta.TDClopwin(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TDClop": (
lambda: ta.TDClop(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TDCamouflage": (
lambda: ta.TDCamouflage(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"PivotReversal": (
lambda: ta.PivotReversal(1, 1),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"ADAPTIVECCI": (lambda: ta.ADAPTIVECCI(20), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"BetterVolume": (
lambda: ta.BetterVolume(14),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"IntradayIntensity": (
lambda: ta.IntradayIntensity(),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"TradeVolumeIndex": (
lambda: ta.TradeVolumeIndex(0.25),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TwiggsMoneyFlow": (
lambda: ta.TwiggsMoneyFlow(21),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"Wad": (
lambda: ta.Wad(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"VolumeRsi": (
lambda: ta.VolumeRsi(14),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
# Per-bar OHLC transforms (open matters). The streaming harness feeds
@@ -899,6 +976,71 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"TDMovingAverage": (
lambda: ta.TDMovingAverage(5, 13),
lambda ind, h, l, c, v: ind.batch(h, l),
2,
),
"VolumeWeightedSr": (
lambda: ta.VolumeWeightedSr(3),
lambda ind, h, l, c, v: ind.batch(h, l, v),
2,
),
"AndrewsPitchfork": (
lambda: ta.AndrewsPitchfork(2),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"MurreyMathLines": (
lambda: ta.MurreyMathLines(4),
lambda ind, h, l, c, v: ind.batch(h, l),
9,
),
"CentralPivotRange": (
lambda: ta.CentralPivotRange(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
3,
),
"VolumeWeightedMacd": (
lambda: ta.VolumeWeightedMacd(12, 26, 9),
lambda ind, h, l, c, v: ind.batch(c, v),
3,
),
"ModifiedMaStop": (
lambda: ta.ModifiedMaStop(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"Nrtr": (
lambda: ta.Nrtr(2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"AtrRatchet": (
lambda: ta.AtrRatchet(14, 4.0, 0.1),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderSafeZone": (
lambda: ta.ElderSafeZone(14, 2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"KaseDevStop": (
lambda: ta.KaseDevStop(3, 1.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ProjectionBands": (
lambda: ta.ProjectionBands(3),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"VolatilityCone": (
lambda: ta.VolatilityCone(20, 60),
lambda ind, h, l, c, v: ind.batch(h, l, c),
5,
),
"KasePermissionStochastic": (
lambda: ta.KasePermissionStochastic(9, 3),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -1535,7 +1677,7 @@ def test_kvo_constant_series_is_zero():
assert v == pytest.approx(0.0, abs=1e-12)
def test_williams_ad_reference():
def test_wad_reference():
# bar 0 seeds prev_close = 10.
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
@@ -2890,6 +3032,209 @@ def test_ppo_histogram_reference():
assert t.update(100.0 + i * 2.0) is None
assert t.update(100.0 + 7 * 2.0) == pytest.approx(-0.052098, abs=1e-6)
def test_ewma_volatility_reference():
t = ta.EwmaVolatility(0.94)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.09531017980432493)
assert t.update(99.0) == pytest.approx(0.0959428936787596)
def test_garch11_reference():
t = ta.Garch11(0.000002, 0.1, 0.88)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.009999999999999995)
assert t.update(99.0) == pytest.approx(0.031597516317477786)
def test_volatility_cone_reference():
t = ta.VolatilityCone(20, 60)
def test_quartile_bands_reference():
t = ta.QuartileBands(4)
assert t.update(40.0) is None
assert t.update(30.0) is None
assert t.update(20.0) is None
assert t.update(10.0) == pytest.approx((32.5, 25.0, 17.5))
def test_bomar_bands_reference():
t = ta.BomarBands(4, 0.85)
assert t.update(100.0) is None
assert t.update(102.0) is None
assert t.update(98.0) is None
assert t.update(104.0) == pytest.approx((104.0, 101.0, 98.0))
def test_median_channel_reference():
t = ta.MedianChannel(5, 2.0)
assert t.update(1.0) is None
assert t.update(2.0) is None
assert t.update(3.0) is None
assert t.update(4.0) is None
assert t.update(5.0) == pytest.approx((5.0, 3.0, 1.0))
def test_projection_bands_reference():
t = ta.ProjectionBands(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx((12.5, 11.25, 10.0))
def test_projection_oscillator_reference():
# Same window as ProjectionBands: upper 12.5, lower 10; close 11 -> 40.
t = ta.ProjectionOscillator(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx(40.0)
def test_kase_devstop_reference():
t = ta.KaseDevStop(3, 1.0)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
assert t.update((101.0, 102.0, 100.0, 101.0, 1.0, 1)) is None
assert t.update((102.0, 103.0, 101.0, 102.0, 1.0, 2)) is None
assert t.update((102.5, 104.0, 102.0, 103.0, 1.0, 3)) == pytest.approx((101.0, 1.0))
def _stop_candles(n):
# Gently rising, valid OHLC: high >= open/close, low <= open/close.
return [(100.0 + i, 101.5 + i, 98.5 + i, 100.5 + i, 1.0, i) for i in range(n)]
def test_elder_safezone_reference():
t = ta.ElderSafeZone(14, 2.0)
candles = _stop_candles(15)
for c in candles[:14]:
assert t.update(c) is None
assert t.update(candles[14]) == pytest.approx((112.5, 1.0))
def test_atr_ratchet_reference():
t = ta.AtrRatchet(14, 4.0, 0.1)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((101.5, 1.0))
def test_nrtr_reference():
t = ta.Nrtr(2.0)
assert t.update((100.0, 100.0, 100.0, 100.0, 1.0, 0)) == pytest.approx((98.0, 1.0))
def test_time_based_stop_reference():
t = ta.TimeBasedStop(5)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) == pytest.approx(0.2)
def test_modified_ma_stop_reference():
t = ta.ModifiedMaStop(14)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
def test_volume_rsi_reference():
t = ta.VolumeRsi(14)
def test_twiggs_money_flow_reference():
t = ta.TwiggsMoneyFlow(21)
def test_trade_volume_index_reference():
t = ta.TradeVolumeIndex(0.25)
def test_intraday_intensity_reference():
t = ta.IntradayIntensity()
def test_better_volume_reference():
t = ta.BetterVolume(14)
def test_volume_weighted_macd_reference():
t = ta.VolumeWeightedMacd(12, 26, 9)
def test_kendall_tau_reference():
t = ta.KendallTau(20)
def test_central_pivot_range_reference():
t = ta.CentralPivotRange()
assert t.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) == pytest.approx((101.66666666666667, 103.33333333333334, 100.0))
def test_murrey_math_lines_reference():
t = ta.MurreyMathLines(4)
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 0)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 1)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 2)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 3)) == pytest.approx((180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, 100.0))
def test_andrews_pitchfork_reference():
t = ta.AndrewsPitchfork(2)
# Warmup: no pitchfork until three alternating swing pivots are confirmed.
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
def test_volume_weighted_sr_reference():
t = ta.VolumeWeightedSr(3)
assert t.update((100.0, 102.0, 98.0, 100.0, 1.0, 0)) is None
assert t.update((100.0, 104.0, 96.0, 100.0, 1.0, 1)) is None
assert t.update((100.0, 106.0, 94.0, 100.0, 1.0, 2)) == pytest.approx((96.0, 104.0))
def test_pivot_reversal_reference():
t = ta.PivotReversal(1, 1)
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 0)) is None
assert t.update((11.5, 12.0, 11.0, 11.5, 1.0, 1)) is None
# Pivot high = 12 confirmed; close 9.5 has not crossed it.
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 2)) == pytest.approx(0.0)
assert t.update((9.0, 11.0, 9.0, 9.0, 1.0, 3)) == pytest.approx(0.0)
# Close 13 > pivot high 12 with prev close 9 below it -> bullish reversal.
assert t.update((13.0, 14.0, 12.5, 13.0, 1.0, 4)) == pytest.approx(1.0)
def test_td_camouflage_reference():
t = ta.TDCamouflage()
assert t.update((10.0, 11.0, 8.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((9.0, 10.0, 7.0, 9.5, 1.0, 1)) == pytest.approx(1.0)
def test_td_clop_reference():
t = ta.TDClop()
assert t.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((9.0, 13.0, 8.0, 12.0, 1.0, 1)) == pytest.approx(1.0)
def test_td_clopwin_reference():
t = ta.TDClopwin()
assert t.update((10.0, 15.0, 9.0, 14.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((11.0, 14.0, 10.0, 13.0, 1.0, 1)) == pytest.approx(1.0)
def test_td_propulsion_reference():
t = ta.TDPropulsion()
assert t.update((9.5, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((10.5, 12.0, 10.0, 11.5, 1.0, 1)) == pytest.approx(1.0)
def test_td_trap_reference():
t = ta.TDTrap()
assert t.update((100.0, 110.0, 90.0, 100.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((101.5, 108.0, 95.0, 102.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((106.0, 112.0, 100.0, 109.0, 1.0, 2)) == pytest.approx(1.0)
# --- Lifecycle ------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+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
+699
View File
@@ -0,0 +1,699 @@
//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`.
//!
//! All four are pure-Rust technical-analysis crates, so this is a like-for-like
//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact
//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`.
//!
//! Two arenas, kept honest:
//!
//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`),
//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand`
//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually
//! here, seeded from `kand`'s own batch output (the seed is computed outside the
//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are
//! exposed through a heavier signal-oriented indicator API, not a raw-value method,
//! so they are intentionally left out rather than compared unfairly.
//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`)
//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API;
//! ta-rs and yata are streaming-only and are deliberately absent from this arena.
//!
//! Run: `cargo bench -p wickra-bench`
// Each indicator's benchmark group spells out every library arm explicitly, which
// runs a few groups over the 100-line lint threshold; that verbosity is the point.
#![allow(clippy::too_many_lines)]
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wickra::{Atr, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
use wickra_data::csv::CandleReader;
use yata::prelude::Method;
const SIZES: &[usize] = &[1_000, 10_000, 50_000];
const SMA_PERIOD: usize = 20;
const EMA_PERIOD: usize = 20;
const RSI_PERIOD: usize = 14;
const ATR_PERIOD: usize = 14;
const BB_PERIOD: usize = 20;
const BB_DEV: f64 = 2.0;
const MACD_FAST: usize = 12;
const MACD_SLOW: usize = 26;
const MACD_SIGNAL: usize = 9;
fn load_candles() -> Vec<Candle> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/data/btcusdt-1m.csv"
);
CandleReader::open(path)
.expect("dataset present")
.read_all()
.expect("valid OHLCV rows")
}
/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`.
fn window_mean(series: &[f64], period: usize) -> f64 {
series[..period].iter().sum::<f64>() / period as f64
}
fn sma_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("sma_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
black_box(ind.batch_nan(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, SMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for idx in SMA_PERIOD..series.len() {
prev = kand::ohlcv::sma::sma_inc(
prev,
series[idx],
series[idx - SMA_PERIOD],
SMA_PERIOD,
)
.unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn ema_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("ema_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
black_box(ind.batch_nan(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, EMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for &price in &series[EMA_PERIOD..] {
prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind =
ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("rsi_14");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
black_box(ind.batch_nan(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Wilder seed: simple average of the first `period` gains and losses.
let mut gain = 0.0;
let mut loss = 0.0;
for idx in 1..=RSI_PERIOD {
let delta = series[idx] - series[idx - 1];
if delta > 0.0 {
gain += delta;
} else {
loss -= delta;
}
}
let seed_gain = gain / RSI_PERIOD as f64;
let seed_loss = loss / RSI_PERIOD as f64;
bencher.iter(|| {
let mut avg_gain = seed_gain;
let mut avg_loss = seed_loss;
let mut prev_price = series[RSI_PERIOD];
for &price in &series[RSI_PERIOD + 1..] {
let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc(
price, prev_price, avg_gain, avg_loss, RSI_PERIOD,
)
.unwrap();
avg_gain = next_gain;
avg_loss = next_loss;
prev_price = price;
black_box(rsi);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut rsi = vec![0.0; series.len()];
let mut avg_gain = vec![0.0; series.len()];
let mut avg_loss = vec![0.0; series.len()];
kand::ohlcv::rsi::rsi(
series,
RSI_PERIOD,
&mut rsi,
&mut avg_gain,
&mut avg_loss,
)
.unwrap();
black_box(&rsi);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn macd_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("macd_12_26_9");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
black_box(ind.batch_macd(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed the fast/slow/signal EMAs from kand's own warmed-up batch state.
let lookback =
kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap();
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
let seed_fast = fast_ema[lookback];
let seed_slow = slow_ema[lookback];
let seed_signal = signal_line[lookback];
bencher.iter(|| {
// macd_inc returns (macd, signal, hist) but not the new EMAs, so the
// fast/slow/signal state is threaded with kand's own ema_inc primitive.
let mut prev_fast = seed_fast;
let mut prev_slow = seed_slow;
let mut prev_signal = seed_signal;
for &price in &series[lookback + 1..] {
let fast =
kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap();
let slow =
kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap();
let macd = fast - slow;
let signal =
kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None)
.unwrap();
prev_fast = fast;
prev_slow = slow;
prev_signal = signal;
black_box((macd, signal, macd - signal));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
black_box(&macd_line);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new(
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
)
.unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("bollinger_20_2");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
black_box(ind.batch_bands(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed running sma/sum/sum_sq from kand's batch state at the warmup edge.
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
let seed_sma = sma[BB_PERIOD - 1];
let seed_sum = sum[BB_PERIOD - 1];
let seed_sum_sq = sum_sq[BB_PERIOD - 1];
bencher.iter(|| {
let mut prev_sma = seed_sma;
let mut prev_sum = seed_sum;
let mut prev_sum_sq = seed_sum_sq;
for idx in BB_PERIOD..series.len() {
let result = kand::ohlcv::bbands::bbands_inc(
series[idx],
prev_sma,
prev_sum,
prev_sum_sq,
series[idx - BB_PERIOD],
BB_PERIOD,
BB_DEV,
BB_DEV,
)
.unwrap();
prev_sma = result.1;
prev_sum = result.4;
prev_sum_sq = result.5;
black_box((result.0, result.1, result.2));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
black_box(&upper);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
let mut group = crit.benchmark_group("atr_14");
for &len in SIZES {
let len = len.min(candles.len());
let series: &[Candle] = &candles[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
for &candle in series {
black_box(ind.update(candle));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
// Column extraction is outside the timed loop, mirroring kand's arm.
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
black_box(ind.batch_atr(&high, &low, &close));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
// Seed prev_atr from kand's batch ATR at the first valid index (= period).
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
let seed_atr = atr_out[ATR_PERIOD];
bencher.iter(|| {
let mut prev_atr = seed_atr;
for idx in ATR_PERIOD + 1..series.len() {
prev_atr = kand::ohlcv::atr::atr_inc(
high[idx],
low[idx],
close[idx - 1],
prev_atr,
ATR_PERIOD,
)
.unwrap();
black_box(prev_atr);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
black_box(&atr_out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
let items: Vec<ta::DataItem> = series
.iter()
.map(|candle| {
ta::DataItem::builder()
.open(candle.open)
.high(candle.high)
.low(candle.low)
.close(candle.close)
.volume(candle.volume)
.build()
.unwrap()
})
.collect();
bencher.iter(|| {
let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap();
for item in &items {
black_box(ta::Next::next(&mut ind, item));
}
});
},
);
}
group.finish();
}
fn benches(crit: &mut Criterion) {
let candles = load_candles();
let closes: Vec<f64> = candles.iter().map(|candle| candle.close).collect();
sma_group(crit, &closes);
ema_group(crit, &closes);
rsi_group(crit, &closes);
macd_group(crit, &closes);
bbands_group(crit, &closes);
atr_group(crit, &candles);
}
criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches);
criterion_main!(cross_lib);
+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.
@@ -0,0 +1,245 @@
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
/// plain SMA, so it leads in trends and stays calm in chop.
///
/// ```text
/// TP = (high + low + close) / 3
/// ER = |TP_t TP_oldest| / Σ |ΔTP| over the window (0..1)
/// sc = ( ER·(2/3 2/31) + 2/31 )²
/// mean += sc·(TP_t mean) (adaptive centre, seeded with SMA)
/// MD = mean(|TP_i mean|) over the window (mean deviation)
/// CCI = (TP_t mean) / (0.015 · MD)
/// ```
///
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
/// lets the centre line accelerate toward price in a clean trend (so the CCI
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
/// The `0.015` scaling keeps Lambert's convention that roughly 7080% of readings
/// fall in `[100, +100]`.
///
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
///
/// let mut indicator = AdaptiveCci::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveCci {
period: usize,
window: VecDeque<f64>,
mean: Option<f64>,
last: Option<f64>,
}
impl AdaptiveCci {
/// Construct an adaptive CCI with the given `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
/// path of at least one step).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "adaptive CCI needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
mean: None,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for AdaptiveCci {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tp = candle.typical_price();
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(tp);
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
// Efficiency ratio over the window.
let oldest = self.window[0];
let direction = (tp - oldest).abs();
let mut path = 0.0;
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
path += (pair[1] - pair[0]).abs();
}
let er = if path > 0.0 {
(direction / path).clamp(0.0, 1.0)
} else {
0.0
};
let fast = 2.0 / 3.0;
let slow = 2.0 / 31.0;
let sc = (er * (fast - slow) + slow).powi(2);
let mean = match self.mean {
None => self.window.iter().sum::<f64>() / n,
Some(prev) => prev + sc * (tp - prev),
};
self.mean = Some(mean);
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
let cci = if md > 0.0 {
(tp - mean) / (0.015 * md)
} else {
0.0
};
self.last = Some(cci);
Some(cci)
}
fn reset(&mut self) {
self.window.clear();
self.mean = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveCci"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(tp: f64) -> Candle {
// open=high=low=close=tp -> typical price == tp.
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
}
#[test]
fn rejects_invalid_period() {
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
assert!(matches!(
AdaptiveCci::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = AdaptiveCci::new(20).unwrap();
assert_eq!(c.period(), 20);
assert_eq!(c.warmup_period(), 20);
assert_eq!(c.name(), "AdaptiveCci");
assert!(!c.is_ready());
assert_eq!(c.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut c = AdaptiveCci::new(4).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
let out = c.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn uptrend_is_positive() {
let mut c = AdaptiveCci::new(10).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
}
#[test]
fn downtrend_is_negative() {
let mut c = AdaptiveCci::new(10).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
}
#[test]
fn flat_window_is_zero() {
let mut c = AdaptiveCci::new(5).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
for v in c.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn reset_clears_state() {
let mut c = AdaptiveCci::new(5).unwrap();
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
c.batch(&candles);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.value(), None);
assert_eq!(c.update(candle(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
let mut b = AdaptiveCci::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,296 @@
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
/// oscillator reacts fast in a clean move and smooths through chop.
///
/// ```text
/// ER = |price_t price_{tperiod}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
/// sc = ( ER·(2/3 2/31) + 2/31 )² (KAMA smoothing constant)
/// avg_gain += sc·(gain avg_gain), avg_loss += sc·(loss avg_loss)
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
/// ```
///
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
/// efficiency ratio (`directional move / total path`) to set the smoothing each
/// bar — near `1` (a clean trend) the averages track gains and losses almost
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
/// is an RSI that is responsive when it should be and quiet when it should be. It
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
/// sets the lookback from the measured dominant cycle.
///
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
/// first value lands after `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveRsi};
///
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveRsi {
period: usize,
prices: VecDeque<f64>,
abs_changes: VecDeque<f64>,
abs_sum: f64,
prev: Option<f64>,
seed_gain: f64,
seed_loss: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl AdaptiveRsi {
/// Construct an adaptive RSI with the given efficiency-ratio `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prices: VecDeque::with_capacity(period + 1),
abs_changes: VecDeque::with_capacity(period),
abs_sum: 0.0,
prev: None,
seed_gain: 0.0,
seed_loss: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured efficiency-ratio period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
fn efficiency_ratio(&self, price: f64) -> f64 {
let oldest = *self.prices.front().expect("window non-empty");
let direction = (price - oldest).abs();
if self.abs_sum == 0.0 {
0.0
} else {
(direction / self.abs_sum).clamp(0.0, 1.0)
}
}
}
impl Indicator for AdaptiveRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let Some(prev) = self.prev else {
self.prev = Some(price);
self.prices.push_back(price);
return None;
};
let change = price - prev;
self.prev = Some(price);
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
// Maintain the price window (period + 1) and the |Δ| window (period).
self.prices.push_back(price);
if self.prices.len() > self.period + 1 {
self.prices.pop_front();
}
if self.abs_changes.len() == self.period {
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
}
self.abs_changes.push_back(change.abs());
self.abs_sum += change.abs();
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let er = self.efficiency_ratio(price);
let fast = 2.0 / 3.0;
let slow = 2.0 / 31.0;
let sc = (er * (fast - slow) + slow).powi(2);
let new_ag = ag + sc * (gain - ag);
let new_al = al + sc * (loss - al);
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gain += gain;
self.seed_loss += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let ag = self.seed_gain / self.period as f64;
let al = self.seed_loss / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prices.clear();
self.abs_changes.clear();
self.abs_sum = 0.0;
self.prev = None;
self.seed_gain = 0.0;
self.seed_loss = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let r = AdaptiveRsi::new(14).unwrap();
assert_eq!(r.period(), 14);
assert_eq!(r.warmup_period(), 15);
assert_eq!(r.name(), "AdaptiveRsi");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AdaptiveRsi::new(4).unwrap();
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn pure_uptrend_is_one_hundred() {
let mut r = AdaptiveRsi::new(5).unwrap();
let last = r
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
let mut r = AdaptiveRsi::new(4).unwrap();
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
}
#[test]
fn output_in_range() {
let mut r = AdaptiveRsi::new(14).unwrap();
for v in r
.batch(
&(0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((0.0..=100.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut r = AdaptiveRsi::new(4).unwrap();
let ready = r
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(r.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut r = AdaptiveRsi::new(4).unwrap();
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
let mut b = AdaptiveRsi::new(14).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,353 @@
//! Andrews Pitchfork — median line and parallels off the last three swing pivots.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`AndrewsPitchfork`]: the three pitchfork lines projected to the
/// current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AndrewsPitchforkOutput {
/// The median line — from the handle pivot through the midpoint of the other two.
pub median: f64,
/// The upper parallel (through the higher of the two anchor pivots).
pub upper: f64,
/// The lower parallel (through the lower of the two anchor pivots).
pub lower: f64,
}
/// A confirmed swing pivot: its bar index and price.
#[derive(Debug, Clone, Copy)]
struct Pivot {
index: f64,
price: f64,
is_high: bool,
}
/// Andrews Pitchfork — Alan Andrews' median-line tool drawn from the three most
/// recent **swing pivots**, projected forward to the current bar.
///
/// ```text
/// detect alternating swing highs/lows with a `strength`-bar fractal
/// P0 = handle (oldest of the last three), P1, P2 = the next two
/// M = midpoint of P1 and P2
/// median(t) = P0 + slope·(t t0) slope = (M P0) / (M_t t0)
/// upper / lower = median(t) offset by the vertical gap to the higher / lower anchor
/// ```
///
/// The pitchfork projects a "fork" of three parallel lines: a central **median
/// line** drawn from a starting pivot through the midpoint of a later swing, plus
/// two parallels passing through that swing's high and low. Price tends to
/// oscillate around the median line and find support/resistance at the parallels.
/// This streaming version detects the pivots automatically with a symmetric
/// fractal of half-width `strength` (so each pivot is confirmed `strength` bars
/// late) and keeps the three most recent alternating swings.
///
/// Because it depends on swing structure, readiness is **data-dependent**: the
/// first output appears once three alternating pivots have been confirmed.
/// `warmup_period` returns the minimum bars to confirm a single pivot. Each
/// `update` is O(`strength`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AndrewsPitchfork};
///
/// let mut indicator = AndrewsPitchfork::new(2).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// // A swinging series eventually establishes a pitchfork.
/// let _ = last;
/// ```
#[derive(Debug, Clone)]
pub struct AndrewsPitchfork {
strength: usize,
window: VecDeque<Candle>,
pivots: Vec<Pivot>,
count: usize,
last: Option<AndrewsPitchforkOutput>,
}
impl AndrewsPitchfork {
/// Construct an Andrews Pitchfork with the given fractal `strength` (bars on
/// each side of a pivot).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `strength == 0`.
pub fn new(strength: usize) -> Result<Self> {
if strength == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
strength,
window: VecDeque::with_capacity(2 * strength + 1),
pivots: Vec::new(),
count: 0,
last: None,
})
}
/// Configured fractal strength.
pub const fn strength(&self) -> usize {
self.strength
}
/// Current value if available.
pub const fn value(&self) -> Option<AndrewsPitchforkOutput> {
self.last
}
/// Record a freshly confirmed pivot, keeping the last three alternating swings.
fn record_pivot(&mut self, pivot: Pivot) {
if let Some(last) = self.pivots.last_mut() {
if last.is_high == pivot.is_high {
// Same kind: keep the more extreme one (and its index).
let more_extreme = if pivot.is_high {
pivot.price > last.price
} else {
pivot.price < last.price
};
if more_extreme {
*last = pivot;
}
return;
}
}
self.pivots.push(pivot);
if self.pivots.len() > 3 {
self.pivots.remove(0);
}
}
fn project(&self, tc: f64) -> Option<AndrewsPitchforkOutput> {
let [p0, p1, p2] = self.pivots.as_slice() else {
return None;
};
let mid_t = f64::midpoint(p1.index, p2.index);
let mid_p = f64::midpoint(p1.price, p2.price);
let slope = (mid_p - p0.price) / (mid_t - p0.index);
let median = p0.price + slope * (tc - p0.index);
let off1 = p1.price - (p0.price + slope * (p1.index - p0.index));
let off2 = p2.price - (p0.price + slope * (p2.index - p0.index));
Some(AndrewsPitchforkOutput {
median,
upper: median + off1.max(off2),
lower: median + off1.min(off2),
})
}
}
impl Indicator for AndrewsPitchfork {
type Input = Candle;
type Output = AndrewsPitchforkOutput;
fn update(&mut self, candle: Candle) -> Option<AndrewsPitchforkOutput> {
self.count += 1;
let span = 2 * self.strength + 1;
if self.window.len() == span {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() == span {
let center = self.window[self.strength];
let is_high = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.high < center.high);
let is_low = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.low > center.low);
// Absolute index of the center bar (1-based count minus the right span).
let center_index = (self.count - 1 - self.strength) as f64;
if is_high && !is_low {
self.record_pivot(Pivot {
index: center_index,
price: center.high,
is_high: true,
});
} else if is_low && !is_high {
self.record_pivot(Pivot {
index: center_index,
price: center.low,
is_high: false,
});
}
}
let tc = (self.count - 1) as f64;
if let Some(out) = self.project(tc) {
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.window.clear();
self.pivots.clear();
self.count = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2 * self.strength + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AndrewsPitchfork"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64) -> Candle {
Candle::new_unchecked(
f64::midpoint(high, low),
high,
low,
f64::midpoint(high, low),
1_000.0,
0,
)
}
/// A clean zig-zag that prints alternating swing highs and lows.
fn zigzag() -> Vec<Candle> {
let mut out = Vec::new();
for i in 0..120 {
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
out.push(c(base + 1.0, base - 1.0));
}
out
}
#[test]
fn rejects_zero_strength() {
assert!(matches!(AndrewsPitchfork::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = AndrewsPitchfork::new(2).unwrap();
assert_eq!(p.strength(), 2);
assert_eq!(p.warmup_period(), 5);
assert_eq!(p.name(), "AndrewsPitchfork");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn none_before_three_pivots() {
let mut p = AndrewsPitchfork::new(2).unwrap();
// Too few bars to ever confirm three alternating pivots.
let out = p.batch(&[c(101.0, 99.0), c(102.0, 100.0), c(101.0, 99.0)]);
assert!(out.iter().all(Option::is_none));
}
#[test]
fn eventually_emits_on_swings() {
let mut p = AndrewsPitchfork::new(2).unwrap();
let out = p.batch(&zigzag());
assert!(
out.iter().any(Option::is_some),
"a swinging series should form a pitchfork"
);
assert!(p.is_ready());
}
#[test]
fn upper_at_or_above_lower() {
let mut p = AndrewsPitchfork::new(2).unwrap();
for o in p.batch(&zigzag()).into_iter().flatten() {
assert!(
o.upper >= o.lower,
"upper {} below lower {}",
o.upper,
o.lower
);
}
}
#[test]
fn reset_clears_state() {
let mut p = AndrewsPitchfork::new(2).unwrap();
p.batch(&zigzag());
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
assert_eq!(p.strength(), 2);
}
#[test]
fn record_pivot_keeps_more_extreme_same_kind() {
let mut p = AndrewsPitchfork::new(2).unwrap();
p.record_pivot(Pivot {
index: 0.0,
price: 100.0,
is_high: true,
});
// A higher high of the same kind replaces the stored one.
p.record_pivot(Pivot {
index: 1.0,
price: 105.0,
is_high: true,
});
assert_eq!(p.pivots.len(), 1);
assert_eq!(p.pivots[0].price, 105.0);
// A lower high of the same kind is ignored.
p.record_pivot(Pivot {
index: 2.0,
price: 102.0,
is_high: true,
});
assert_eq!(p.pivots.len(), 1);
assert_eq!(p.pivots[0].price, 105.0);
// A low pivot of the other kind is appended.
p.record_pivot(Pivot {
index: 3.0,
price: 90.0,
is_high: false,
});
assert_eq!(p.pivots.len(), 2);
// A lower low of the same kind replaces the stored low.
p.record_pivot(Pivot {
index: 4.0,
price: 85.0,
is_high: false,
});
assert_eq!(p.pivots[1].price, 85.0);
// A higher low of the same kind is ignored.
p.record_pivot(Pivot {
index: 5.0,
price: 88.0,
is_high: false,
});
assert_eq!(p.pivots[1].price, 85.0);
}
#[test]
fn batch_equals_streaming() {
let candles = zigzag();
let batch = AndrewsPitchfork::new(2).unwrap().batch(&candles);
let mut b = AndrewsPitchfork::new(2).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+163 -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,72 @@ impl Atr {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.avg
if self.seeded {
Some(self.avg)
} else {
None
}
}
/// Vectorized batch over raw high/low/close columns: one `f64` per bar
/// (`NaN` during warmup). The caller guarantees the three slices are equal
/// length and finite with valid OHLC ordering (the binding validates once up
/// front); ATR only reads high, low and the previous close.
///
/// For a fresh indicator long enough to seed (`n >= period`) it runs the
/// true-range seed once and then the bare Wilder recurrence in a tight loop —
/// no per-bar `Candle` construction/validation, no `Option`, identical
/// division at the seed and `mul_add` afterwards, so the result is
/// *bit-for-bit* equal to replaying `update` over the same candles. Shorter
/// or non-fresh inputs defer to an exact `update` replay.
pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
let p = self.period;
let n = high.len();
if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p {
let mut out = vec![f64::NAN; n];
for i in 0..n {
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
if let Some(v) = self.update(candle) {
out[i] = v;
}
}
return out;
}
// Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`.
let mut out = vec![f64::NAN; p - 1];
out.reserve(n - (p - 1));
// Seed: mean of the first `period` true ranges. TR₀ has no previous close.
let mut prev_close = close[0];
let mut sum_tr = high[0] - low[0];
self.seed_buf.push(sum_tr);
for i in 1..p {
let (h, l) = (high[i], low[i]);
let tr = (h - l)
.max((h - prev_close).abs())
.max((l - prev_close).abs());
prev_close = close[i];
self.seed_buf.push(tr);
sum_tr += tr;
}
let mut avg = sum_tr / p as f64;
out.push(avg);
// Steady state: Wilder smoothing, reciprocal hoisted out of the loop.
for i in p..n {
let (h, l) = (high[i], low[i]);
let tr = (h - l)
.max((h - prev_close).abs())
.max((l - prev_close).abs());
prev_close = close[i];
avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period;
out.push(avg);
}
// Leave state where a full `update` replay would (seeded; seed_buf retained).
self.prev_close = Some(prev_close);
self.avg = avg;
self.seeded = true;
out
}
}
@@ -70,17 +146,18 @@ impl Indicator for Atr {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if let Some(avg) = self.avg {
let n = self.period as f64;
let new_avg = avg.mul_add(n - 1.0, tr) / n;
self.avg = Some(new_avg);
if self.seeded {
// Wilder smoothing with the reciprocal hoisted out of the hot path.
let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
self.avg = new_avg;
return Some(new_avg);
}
self.seed_buf.push(tr);
if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed);
self.avg = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -89,7 +166,8 @@ impl Indicator for Atr {
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf.clear();
self.avg = None;
self.avg = 0.0;
self.seeded = false;
}
fn warmup_period(&self) -> usize {
@@ -97,7 +175,7 @@ impl Indicator for Atr {
}
fn is_ready(&self) -> bool {
self.avg.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -249,6 +327,81 @@ mod tests {
}
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
let mut a = Atr::new(period).unwrap();
(0..high.len())
.map(|i| {
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
a.update(candle).unwrap_or(f64::NAN)
})
.collect()
}
/// Valid OHLC columns from a wandering base price.
fn columns(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let base: Vec<f64> = (0..n)
.map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0)
.collect();
let high = base.iter().map(|b| b + 1.0).collect();
let low = base.iter().map(|b| b - 1.0).collect();
(high, low, base)
}
#[test]
fn batch_atr_fast_path_is_bit_identical() {
let (high, low, close) = columns(300);
let mut atr = Atr::new(14).unwrap();
let got = atr.batch_atr(&high, &low, &close);
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
let mut ref_atr = Atr::new(14).unwrap();
for i in 0..high.len() {
ref_atr.update(Candle::new_unchecked(
close[i], high[i], low[i], close[i], 0.0, 0,
));
}
let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0);
assert_eq!(atr.update(next), ref_atr.update(next));
}
#[test]
fn batch_atr_falls_back_when_not_fresh() {
let (high, low, close) = columns(40);
let mut atr = Atr::new(14).unwrap();
atr.update(Candle::new_unchecked(
close[0], high[0], low[0], close[0], 0.0, 0,
));
let mut ref_atr = Atr::new(14).unwrap();
ref_atr.update(Candle::new_unchecked(
close[0], high[0], low[0], close[0], 0.0, 0,
));
let want: Vec<f64> = (0..high.len())
.map(|i| {
ref_atr
.update(Candle::new_unchecked(
close[i], high[i], low[i], close[i], 0.0, 0,
))
.unwrap_or(f64::NAN)
})
.collect();
assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want));
}
#[test]
fn batch_atr_sub_period_slice_falls_back() {
let (high, low, close) = columns(5);
let mut atr = Atr::new(14).unwrap();
let got = atr.batch_atr(&high, &low, &close);
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
assert!(got.iter().all(|x| x.is_nan()));
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
@@ -0,0 +1,279 @@
//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtrRatchetOutput {
/// The ratchet stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
/// fixed fraction of ATR **every bar**, whether or not price moves.
///
/// ```text
/// on entry (long): stop = close start_mult · ATR
/// each later bar: stop = stop + increment · ATR (ratchets toward price)
/// flip to short when close < stop, reseeding stop = close + start_mult · ATR
/// ```
///
/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
/// instead advances the stop a little each bar — `increment · ATR` — so a trade
/// that stalls is squeezed out over time even in a flat market. The initial
/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
/// `increment` controls how aggressively the leash shortens. When price closes
/// through the stop the system reverses and reseeds at the full initial distance.
///
/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AtrRatchet};
///
/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrRatchet {
atr: Atr,
atr_period: usize,
start_mult: f64,
increment: f64,
direction: f64,
stop: f64,
last: Option<AtrRatchetOutput>,
}
impl AtrRatchet {
/// Construct an ATR Ratchet stop.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
/// finite and positive.
pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
if !start_mult.is_finite()
|| start_mult <= 0.0
|| !increment.is_finite()
|| increment <= 0.0
{
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
atr_period,
start_mult,
increment,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(atr_period, start_mult, increment)`.
pub const fn params(&self) -> (usize, f64, f64) {
(self.atr_period, self.start_mult, self.increment)
}
/// Current value if available.
pub const fn value(&self) -> Option<AtrRatchetOutput> {
self.last
}
}
impl Indicator for AtrRatchet {
type Input = Candle;
type Output = AtrRatchetOutput;
fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
let atr = self.atr.update(candle)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
} else if self.direction > 0.0 {
self.stop += self.increment * atr;
if close < self.stop {
self.direction = -1.0;
self.stop = close + self.start_mult * atr;
}
} else {
self.stop -= self.increment * atr;
if close > self.stop {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
}
}
let out = AtrRatchetOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.atr.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AtrRatchet"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
AtrRatchet::new(0, 4.0, 0.1),
Err(Error::PeriodZero)
));
assert!(matches!(
AtrRatchet::new(14, 0.0, 0.1),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
assert_eq!(r.params(), (14, 4.0, 0.1));
assert_eq!(r.warmup_period(), 14);
assert_eq!(r.name(), "AtrRatchet");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = r.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn stall_eventually_triggers_flip() {
// A long trend then a long flat stretch: the ratchet creeps up each bar
// and eventually overtakes the flat close, flipping to short.
let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
let mut candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
// Flat stretch at the last price.
candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
let dirs: Vec<f64> = r
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(
dirs.iter().any(|&d| d < 0.0),
"the ratchet should eventually flip short"
);
}
#[test]
fn reset_clears_state() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
r.batch(&candles);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,340 @@
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use std::f64::consts::TAU;
use crate::error::{Error, Result};
use crate::indicators::roofing_filter::RoofingFilter;
use crate::traits::Indicator;
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
const AVG_LENGTH: usize = 3;
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
/// period** of the market by correlating a roofing-filtered price with lagged
/// copies of itself and reading off the spectral peak.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
///
/// ```text
/// Filt = RoofingFilter(price) (detrend + denoise)
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
/// for each candidate period:
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t1} (EMA across time)
/// normalise by a decaying max, then
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
/// ```
///
/// The autocorrelation function emphasises whatever cycle is actually present and
/// suppresses noise; transforming it into a periodogram and taking the
/// power-weighted centre of gravity gives a smooth, robust estimate of the
/// dominant cycle length. That cycle is the key input for every *adaptive*
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
/// output is a period in bars within `[min_period, max_period]`.
///
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
/// O(`max_period²`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
/// use std::f64::consts::TAU;
///
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AutocorrelationPeriodogram {
min_period: usize,
max_period: usize,
roof: RoofingFilter,
buffer: VecDeque<f64>,
r: Vec<f64>,
max_pwr: f64,
last: Option<f64>,
}
impl AutocorrelationPeriodogram {
/// Construct an autocorrelation periodogram searching cycles in
/// `[min_period, max_period]`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, or
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
/// `max_period <= min_period`.
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
if min_period == 0 || max_period == 0 {
return Err(Error::PeriodZero);
}
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
return Err(Error::InvalidPeriod {
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
});
}
Ok(Self {
min_period,
max_period,
roof: RoofingFilter::new(10, max_period)?,
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
r: vec![0.0; max_period + 1],
max_pwr: 0.0,
last: None,
})
}
/// Configured `(min_period, max_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.min_period, self.max_period)
}
/// Current dominant-cycle estimate if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
fn correlation(&self, lag: usize) -> f64 {
let len = self.buffer.len();
let filt = |k: usize| self.buffer[len - 1 - k];
let m = AVG_LENGTH as f64;
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
for count in 0..AVG_LENGTH {
let x = filt(count);
let y = filt(lag + count);
sx += x;
sy += y;
sxx += x * x;
syy += y * y;
sxy += x * y;
}
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
if denom > 0.0 {
(m * sxy - sx * sy) / denom.sqrt()
} else {
0.0
}
}
}
impl Indicator for AutocorrelationPeriodogram {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.roof.update(price)?;
if self.buffer.len() == self.max_period + AVG_LENGTH {
self.buffer.pop_front();
}
self.buffer.push_back(filt);
if self.buffer.len() < self.max_period + AVG_LENGTH {
return None;
}
// Autocorrelation across lags.
let mut corr = vec![0.0; self.max_period + 1];
for (lag, c) in corr.iter_mut().enumerate() {
*c = self.correlation(lag);
}
// Periodogram: spectral power for each candidate period, EMA'd over time.
self.max_pwr *= 0.995;
for period in self.min_period..=self.max_period {
let mut cosine = 0.0;
let mut sine = 0.0;
for (n, &cn) in corr
.iter()
.enumerate()
.take(self.max_period + 1)
.skip(AVG_LENGTH)
{
let angle = TAU * n as f64 / period as f64;
cosine += cn * angle.cos();
sine += cn * angle.sin();
}
let power = cosine * cosine + sine * sine;
self.r[period] = 0.2 * power + 0.8 * self.r[period];
if self.r[period] > self.max_pwr {
self.max_pwr = self.r[period];
}
}
// Power-weighted centre of gravity of the strong periods.
let mut spx = 0.0;
let mut sp = 0.0;
for period in self.min_period..=self.max_period {
let pwr = if self.max_pwr > 0.0 {
self.r[period] / self.max_pwr
} else {
0.0
};
if pwr >= 0.5 {
spx += period as f64 * pwr;
sp += pwr;
}
}
let dominant = if sp > 0.0 {
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
} else {
self.min_period as f64
};
self.last = Some(dominant);
Some(dominant)
}
fn reset(&mut self) {
self.roof.reset();
self.buffer.clear();
self.r.iter_mut().for_each(|x| *x = 0.0);
self.max_pwr = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.max_period + AVG_LENGTH
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AutocorrelationPeriodogram"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
AutocorrelationPeriodogram::new(0, 48),
Err(Error::PeriodZero)
));
assert!(matches!(
AutocorrelationPeriodogram::new(3, 48),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
AutocorrelationPeriodogram::new(48, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
assert_eq!(p.periods(), (10, 48));
assert_eq!(p.warmup_period(), 51);
assert_eq!(p.name(), "AutocorrelationPeriodogram");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
let xs: Vec<f64> = (0..40)
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
.collect();
let out = p.batch(&xs);
let warmup = p.warmup_period(); // 23
assert_eq!(warmup, 23);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn output_within_period_band() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
for v in p.batch(&xs).into_iter().flatten() {
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
}
}
#[test]
fn detects_injected_cycle() {
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
let xs: Vec<f64> = (0..600)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
assert!(
(last - 20.0).abs() < 6.0,
"expected ~20-bar cycle, got {last}"
);
}
#[test]
fn ignores_non_finite() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
p.batch(
&(0..80)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect::<Vec<_>>(),
);
let before = p.value();
assert_eq!(p.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
p.batch(
&(0..120)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect::<Vec<_>>(),
);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..200)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_input_falls_back_to_min_period() {
// Constant input has zero variance, so every lag correlation is
// degenerate (denom <= 0), the max power is zero and no period clears
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
let flat = [100.0_f64; 200];
let last = AutocorrelationPeriodogram::new(10, 48)
.unwrap()
.batch(&flat)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 10.0);
}
}
@@ -0,0 +1,243 @@
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
/// around a target `period` and rejects both the trend (low frequencies) and the
/// noise (high frequencies).
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// beta = cos(2π / period)
/// gamma = 1 / cos(4π · bandwidth / period)
/// alpha = gamma sqrt(gamma² 1)
/// BP_t = 0.5·(1 alpha)·(price_t price_{t2})
/// + beta·(1 + alpha)·BP_{t1} alpha·BP_{t2}
/// ```
///
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
/// and cycle-amplitude work.
///
/// The recursion needs two prior prices and two prior outputs; until then it emits
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
/// produced every bar. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, BandpassFilter};
///
/// let mut indicator = BandpassFilter::new(20, 0.3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BandpassFilter {
period: usize,
bandwidth: f64,
beta: f64,
alpha: f64,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
bp1: f64,
bp2: f64,
last: Option<f64>,
}
impl BandpassFilter {
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
/// fraction.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
/// `(0, 1)`.
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
return Err(Error::InvalidParameter {
message: "bandpass bandwidth must be in (0, 1)",
});
}
let period_f = period as f64;
let beta = (2.0 * PI / period_f).cos();
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
Ok(Self {
period,
bandwidth,
beta,
alpha,
prev_price_1: None,
prev_price_2: None,
bp1: 0.0,
bp2: 0.0,
last: None,
})
}
/// Configured `(period, bandwidth)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.bandwidth)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BandpassFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let bp = match self.prev_price_2 {
Some(p2) => {
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
- self.alpha * self.bp2
}
None => 0.0,
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.bp2 = self.bp1;
self.bp1 = bp;
self.last = Some(bp);
Some(bp)
}
fn reset(&mut self) {
self.prev_price_1 = None;
self.prev_price_2 = None;
self.bp1 = 0.0;
self.bp2 = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BandpassFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
BandpassFilter::new(0, 0.3),
Err(Error::PeriodZero)
));
assert!(matches!(
BandpassFilter::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BandpassFilter::new(20, 1.0),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bp = BandpassFilter::new(20, 0.3).unwrap();
assert_eq!(bp.params(), (20, 0.3));
assert_eq!(bp.warmup_period(), 1);
assert_eq!(bp.name(), "BandpassFilter");
assert!(!bp.is_ready());
assert_eq!(bp.value(), None);
}
#[test]
fn first_bars_are_zero() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
assert_eq!(bp.update(100.0), Some(0.0));
assert_eq!(bp.update(101.0), Some(0.0));
// From the third bar the recursion is active.
assert!(bp.is_ready());
}
#[test]
fn constant_input_stays_zero() {
// A trend-free flat input has no cyclic content -> output stays 0.
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn cyclic_input_oscillates_around_zero() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
let mean = out.iter().sum::<f64>() / out.len() as f64;
assert!(
mean.abs() < 1.0,
"bandpass output should be ~zero mean, got {mean}"
);
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = bp.value();
assert_eq!(bp.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(bp.is_ready());
bp.reset();
assert!(!bp.is_ready());
assert_eq!(bp.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
let mut b = BandpassFilter::new(20, 0.3).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,254 @@
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
/// oscillator: how much volume (effort) a bar spent relative to the price range
/// (result) it achieved, both normalised against their own recent averages.
///
/// ```text
/// range_t = high_t low_t
/// rel_vol = volume_t / SMA(volume, period)
/// rel_range = range_t / SMA(range, period)
/// BetterVol = rel_vol rel_range
/// ```
///
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
/// through the relationship between **effort** (volume) and **result** (the bar's
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
/// produced little movement, the hallmark of absorption (supply meeting demand at
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
///
/// Both legs are normalised by their `period` simple moving averages (including
/// the current bar), so the output is centred near `0` and self-scales to the
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BetterVolume};
///
/// let mut indicator = BetterVolume::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BetterVolume {
period: usize,
volumes: VecDeque<f64>,
ranges: VecDeque<f64>,
vol_sum: f64,
range_sum: f64,
last: Option<f64>,
}
impl BetterVolume {
/// Construct a new Better Volume oscillator with the given averaging `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
volumes: VecDeque::with_capacity(period),
ranges: VecDeque::with_capacity(period),
vol_sum: 0.0,
range_sum: 0.0,
last: None,
})
}
/// Configured averaging period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BetterVolume {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
if self.volumes.len() == self.period {
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
self.range_sum -= self.ranges.pop_front().expect("non-empty");
}
self.volumes.push_back(candle.volume);
self.ranges.push_back(range);
self.vol_sum += candle.volume;
self.range_sum += range;
if self.volumes.len() < self.period {
return None;
}
let n = self.period as f64;
let sma_vol = self.vol_sum / n;
let sma_range = self.range_sum / n;
let rel_vol = if sma_vol > 0.0 {
candle.volume / sma_vol
} else {
0.0
};
let rel_range = if sma_range > 0.0 {
range / sma_range
} else {
0.0
};
let out = rel_vol - rel_range;
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.volumes.clear();
self.ranges.clear();
self.vol_sum = 0.0;
self.range_sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BetterVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, high, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bv = BetterVolume::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 20);
assert_eq!(bv.name(), "BetterVolume");
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let out = bv.batch(&candles);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn steady_bars_are_neutral() {
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
let mut bv = BetterVolume::new(4).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn churn_bar_is_positive() {
// Three normal bars, then a high-volume narrow-range bar -> positive.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "churn bar should be positive, got {last}");
}
#[test]
fn ease_of_movement_bar_is_negative() {
// Three normal bars, then a wide-range light-volume bar -> negative.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < 0.0,
"ease-of-movement bar should be negative, got {last}"
);
}
#[test]
fn zero_everything_is_zero() {
// Zero volume and zero range -> both legs guarded to 0.
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
for v in bv.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut bv = BetterVolume::new(3).unwrap();
bv.batch(
&(0..6)
.map(|_| candle(102.0, 100.0, 1_000.0))
.collect::<Vec<_>>(),
);
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(
base + 2.0,
base - 1.5,
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
)
})
.collect();
let batch = BetterVolume::new(20).unwrap().batch(&candles);
let mut b = BetterVolume::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,281 @@
//! Realized Bipower Variation — a jump-robust quadratic-variation estimator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Realized Bipower Variation — the sum of *adjacent* absolute log-return
/// products over the trailing `period` returns, scaled to estimate integrated
/// variance.
///
/// ```text
/// r_t = ln(price_t / price_{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);
}
}
+183 -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,
@@ -102,8 +108,84 @@ impl BollingerBands {
self.multiplier
}
/// Vectorized flat batch for bindings: returns `n * 4` values laid out as
/// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`.
///
/// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq`
/// and drift-reseed, writing the four band values directly instead of an
/// `Option<BollingerOutput>` per element. Same add/subtract order, same reseed
/// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to
/// replaying `update`, including the long-stream drift bound. Any other state,
/// or a non-finite element, defers to the exact `update` replay.
///
/// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch),
/// which returns `Vec<Option<BollingerOutput>>`; only the bindings, which want
/// a flat `f64` buffer, call this.
pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
let p = self.period;
let n = inputs.len();
if self.count != 0
|| self.updates_since_recompute != 0
|| !inputs.iter().all(|x| x.is_finite())
{
// Slow path: exact replay of `update` into the flat layout.
let mut out = vec![f64::NAN; n * 4];
for (i, &x) in inputs.iter().enumerate() {
if let Some(o) = self.update(x) {
out[i * 4] = o.upper;
out[i * 4 + 1] = o.middle;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.stddev;
}
}
return out;
}
let p_f64 = p as f64;
let mult = self.multiplier;
// Pre-sized output: warmup rows stay NaN, ready rows are written in place
// by index — no per-row `push` length/capacity check.
let mut out = vec![f64::NAN; n * 4];
for (i, &x) in inputs.iter().enumerate() {
if self.count == p {
let old = self.buf[self.head];
self.sum -= old;
self.sum_sq -= old * old;
self.buf[self.head] = x;
self.sum += x;
self.sum_sq += x * x;
} else {
self.buf[self.head] = x;
self.sum += x;
self.sum_sq += x * x;
self.count += 1;
}
self.head += 1;
if self.head == p {
self.head = 0;
}
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
self.sum = chronological.clone().copied().sum();
self.sum_sq = chronological.map(|&v| v * v).sum();
self.updates_since_recompute = 0;
}
if self.count == p {
let mean = self.sum / p_f64;
let stddev = (self.sum_sq / p_f64 - mean * mean).max(0.0).sqrt();
let band = mult * stddev;
out[i * 4] = mean + band;
out[i * 4 + 1] = mean;
out[i * 4 + 2] = mean - band;
out[i * 4 + 3] = stddev;
}
}
out
}
fn current(&self) -> Option<BollingerOutput> {
if self.window.len() != self.period {
if self.count != self.period {
return None;
}
let n = self.period as f64;
@@ -129,25 +211,38 @@ impl Indicator for BollingerBands {
if !input.is_finite() {
return self.current();
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
if self.count == self.period {
let old = self.buf[self.head];
self.sum -= old;
self.sum_sq -= old * old;
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
} else {
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
self.count += 1;
}
self.head += 1;
if self.head == self.period {
self.head = 0;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
// Reseed in chronological order (oldest at `head`) to keep the running
// sums bit-equivalent to a fresh from-scratch pass on stable inputs.
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
self.sum = chronological.clone().copied().sum();
self.sum_sq = chronological.map(|&x| x * x).sum();
self.updates_since_recompute = 0;
}
self.current()
}
fn reset(&mut self) {
self.window.clear();
self.head = 0;
self.count = 0;
self.sum = 0.0;
self.sum_sq = 0.0;
self.updates_since_recompute = 0;
@@ -158,7 +253,7 @@ impl Indicator for BollingerBands {
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
self.count == self.period
}
fn name(&self) -> &'static str {
@@ -171,6 +266,7 @@ mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
use std::collections::VecDeque;
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!(
@@ -332,6 +428,79 @@ mod tests {
);
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
/// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
let mut bb = BollingerBands::new(period, mult).unwrap();
let mut out = Vec::with_capacity(series.len() * 4);
for &x in series {
match bb.update(x) {
Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
None => out.extend_from_slice(&[f64::NAN; 4]),
}
}
out
}
#[test]
fn batch_bands_fast_path_is_bit_identical_with_reseed() {
// > 16*period inputs so the drift-reseed branch fires inside batch_bands.
let series: Vec<f64> = (0..500)
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
.collect();
let mut bb = BollingerBands::new(20, 2.0).unwrap();
let got = bb.batch_bands(&series);
assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
// State continues identically.
let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
for &x in &series {
ref_bb.update(x);
}
assert_eq!(bb.update(55.0), ref_bb.update(55.0));
}
#[test]
fn batch_bands_falls_back_on_non_finite() {
let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
let mut bb = BollingerBands::new(3, 2.0).unwrap();
assert!(bits_eq(
&bb.batch_bands(&series),
&bb_replay(3, 2.0, &series)
));
}
#[test]
fn batch_bands_falls_back_when_not_fresh() {
let mut bb = BollingerBands::new(3, 2.0).unwrap();
bb.update(99.0);
let series = [1.0, 2.0, 3.0, 4.0];
let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
ref_bb.update(99.0);
let mut want = Vec::new();
for &x in &series {
match ref_bb.update(x) {
Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
None => want.extend_from_slice(&[f64::NAN; 4]),
}
}
assert!(bits_eq(&bb.batch_bands(&series), &want));
}
#[test]
fn batch_bands_sub_period_slice_is_all_nan() {
let series = [1.0, 2.0, 3.0];
let mut bb = BollingerBands::new(10, 2.0).unwrap();
let got = bb.batch_bands(&series);
assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
}
#[test]
fn ignores_non_finite_input() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
@@ -0,0 +1,256 @@
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
//! recent price.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Bomar Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BomarBandsOutput {
/// Upper band: `middle + |middle| · p`.
pub upper: f64,
/// Middle line: the simple moving average over the window.
pub middle: f64,
/// Lower band: `middle |middle| · p`.
pub lower: f64,
}
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
/// fraction of recent closes falls inside them.
///
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
/// inspiration — percentage bands around a moving average, with the percentage
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
/// realises that idea deterministically: the half-width is the `coverage`
/// quantile of the relative deviations from the midline, so by construction
/// `coverage` of the window's closes lie inside the bands.
///
/// ```text
/// middle = SMA(close, period)
/// dev_i = | close_i / middle 1 | // relative distance from midline
/// p = coverage-quantile of { dev_i } // type-7 interpolation
/// upper = middle + |middle| · p
/// lower = middle |middle| · p
/// ```
///
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
/// here is data-driven: the bands widen in turbulent regimes and tighten in
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
/// an order statistic of the actual deviations rather than a multiple of the
/// standard deviation, so it is unaffected by the shape of the tails beyond the
/// `coverage` rank. When the midline is zero the relative deviation is
/// undefined and the bands collapse onto the midline.
///
/// # Example
///
/// ```
/// use wickra_core::{BomarBands, Indicator};
///
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 7));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BomarBands {
period: usize,
coverage: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl BomarBands {
/// Construct new Bomar Bands.
///
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
/// `(0.0, 1.0]`.
pub fn new(period: usize, coverage: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
return Err(Error::InvalidParameter {
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
});
}
Ok(Self {
period,
coverage,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured coverage fraction.
pub const fn coverage(&self) -> f64 {
self.coverage
}
}
impl Indicator for BomarBands {
type Input = f64;
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let sum: f64 = self.window.iter().sum();
let middle = sum / (self.period as f64);
let denom = middle.abs();
self.scratch.clear();
for &v in &self.window {
let dev = if denom == 0.0 {
0.0
} else {
((v - middle) / denom).abs()
};
self.scratch.push(dev);
}
self.scratch.sort_by(f64::total_cmp);
let p = quantile_sorted(&self.scratch, self.coverage);
let offset = denom * p;
Some(BomarBandsOutput {
upper: middle + offset,
middle,
lower: middle - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BomarBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
assert!(BomarBands::new(1, 0.85).is_ok());
}
#[test]
fn rejects_out_of_range_coverage() {
assert!(matches!(
BomarBands::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, -0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bb = BomarBands::new(20, 0.85).unwrap();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BomarBands");
assert!(!bb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
assert!(bb.update(100.0).is_none());
assert!(bb.update(102.0).is_none());
assert!(bb.update(98.0).is_none());
assert!(bb.update(104.0).is_some());
assert!(bb.is_ready());
}
#[test]
fn known_bands() {
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn zero_midline_collapses_bands() {
// Window mean exactly zero -> relative deviation undefined -> collapse.
let mut bb = BomarBands::new(2, 0.85).unwrap();
let out = bb.batch(&[3.0, -3.0]);
let last = out[1].unwrap();
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_bands` window.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
for v in [100.0, 102.0, 98.0, 104.0] {
bb.update(v);
}
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
assert!(bb.update(100.0).is_none());
}
}
@@ -0,0 +1,171 @@
//! Central Pivot Range (CPR) — the pivot plus its two central levels.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`CentralPivotRange`]: the pivot and the two central lines that
/// bracket it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CentralPivotRangeOutput {
/// Pivot point `(high + low + close) / 3`.
pub pivot: f64,
/// Top central line — the higher of the two central levels.
pub tc: f64,
/// Bottom central line — the lower of the two central levels.
pub bc: f64,
}
/// Central Pivot Range (CPR) — the classic pivot point flanked by two "central"
/// levels whose separation gauges the day's expected character.
///
/// ```text
/// pivot = (high + low + close) / 3
/// bc' = (high + low) / 2
/// tc' = 2·pivot bc'
/// TC = max(tc', bc'), BC = min(tc', bc')
/// ```
///
/// The CPR is computed from the **previous** period's bar (feed it completed
/// daily/weekly bars). The width of the range `TC BC` is the headline read: a
/// **narrow** CPR signals a likely trending day (price has little balance area to
/// chew through), while a **wide** CPR signals a likely range-bound, balanced
/// day. Price opening above the whole range is bullish, below it bearish, inside
/// it neutral. The `tc'`/`bc'` formulas are symmetric about the pivot; this
/// implementation labels the larger as `TC` and the smaller as `BC` so `TC >= BC`
/// always holds.
///
/// There are no parameters and no warmup — each completed bar yields one CPR.
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, CentralPivotRange};
///
/// let mut indicator = CentralPivotRange::new();
/// let prev_day = Candle::new(101.0, 110.0, 90.0, 105.0, 1_000.0, 0).unwrap();
/// let cpr = indicator.update(prev_day).unwrap();
/// assert!(cpr.tc >= cpr.bc);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CentralPivotRange {
ready: bool,
}
impl CentralPivotRange {
/// Construct a new Central Pivot Range. The indicator is parameter-free.
#[must_use]
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for CentralPivotRange {
type Input = Candle;
type Output = CentralPivotRangeOutput;
fn update(&mut self, candle: Candle) -> Option<CentralPivotRangeOutput> {
let pivot = (candle.high + candle.low + candle.close) / 3.0;
let bc_raw = f64::midpoint(candle.high, candle.low);
let tc_raw = 2.0 * pivot - bc_raw;
let tc = tc_raw.max(bc_raw);
let bc = tc_raw.min(bc_raw);
self.ready = true;
Some(CentralPivotRangeOutput { pivot, tc, bc })
}
fn reset(&mut self) {
self.ready = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"CentralPivotRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
}
#[test]
fn accessors_and_metadata() {
let cpr = CentralPivotRange::new();
assert_eq!(cpr.warmup_period(), 1);
assert_eq!(cpr.name(), "CentralPivotRange");
assert!(!cpr.is_ready());
}
#[test]
fn formula_reference_values() {
// H=110, L=90, C=105 -> pivot = 305/3; bc' = 100; tc' = 2*pivot - 100.
let out = CentralPivotRange::new()
.update(c(110.0, 90.0, 105.0))
.unwrap();
let pivot = 305.0 / 3.0;
let bc_raw = 100.0;
let tc_raw = 2.0 * pivot - bc_raw;
assert!((out.pivot - pivot).abs() < 1e-12);
assert!((out.tc - tc_raw.max(bc_raw)).abs() < 1e-12);
assert!((out.bc - tc_raw.min(bc_raw)).abs() < 1e-12);
}
#[test]
fn tc_never_below_bc() {
let out = CentralPivotRange::new()
.update(c(200.0, 100.0, 150.0))
.unwrap();
assert!(out.tc >= out.bc);
}
#[test]
fn constant_bar_collapses_range() {
// H = L = C -> pivot = bc' = tc' = the price; range collapses.
let out = CentralPivotRange::new()
.update(c(50.0, 50.0, 50.0))
.unwrap();
assert_eq!(out.pivot, 50.0);
assert_eq!(out.tc, 50.0);
assert_eq!(out.bc, 50.0);
}
#[test]
fn ready_after_first_update() {
let mut cpr = CentralPivotRange::new();
assert!(!cpr.is_ready());
cpr.update(c(11.0, 9.0, 10.0));
assert!(cpr.is_ready());
}
#[test]
fn reset_clears_state() {
let mut cpr = CentralPivotRange::new();
cpr.update(c(11.0, 9.0, 10.0));
assert!(cpr.is_ready());
cpr.reset();
assert!(!cpr.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
.collect();
let batch = CentralPivotRange::new().batch(&candles);
let mut b = CentralPivotRange::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,258 @@
//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation
/// coefficient between price and a perfectly straight ramp over the lookback.
///
/// ```text
/// CTI = corr( price over the window , [0, 1, …, period1] )
/// ```
///
/// John Ehlers' CTI asks "how closely does recent price track a straight line?"
/// by correlating the windowed price against the time index itself. A reading near
/// `+1` means price is rising in a near-perfect line (strong uptrend); near `1`
/// means a clean downtrend; near `0` means no linear trend (a range or choppy
/// market). Because correlation is scale- and offset-invariant, the slope's
/// steepness does not matter — only how *linear* the move is — which makes CTI an
/// unusually clean trend/range classifier. It differs from
/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a
/// *lagged copy of itself* rather than with time.
///
/// The output is in `[1, +1]`; a flat window (zero price variance) returns `0`.
/// The first value lands after `period` inputs; each `update` recomputes the
/// correlation over the window in O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CorrelationTrendIndicator};
///
/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct CorrelationTrendIndicator {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl CorrelationTrendIndicator {
/// Construct a CTI over `period` bars.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two
/// points).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "CTI needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn compute(&self) -> f64 {
let n = self.period as f64;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
let mut sum_xt = 0.0;
for (i, &x) in self.window.iter().enumerate() {
let t = i as f64;
sum_x += x;
sum_xx += x * x;
sum_xt += x * t;
}
// Time index 0..n-1 has closed-form sums.
let sum_t = n * (n - 1.0) / 2.0;
let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
let cov = n * sum_xt - sum_x * sum_t;
let var_x = n * sum_xx - sum_x * sum_x;
let var_t = n * sum_tt - sum_t * sum_t;
let denom = (var_x * var_t).sqrt();
if denom == 0.0 {
0.0
} else {
(cov / denom).clamp(-1.0, 1.0)
}
}
}
impl Indicator for CorrelationTrendIndicator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"CorrelationTrendIndicator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(matches!(
CorrelationTrendIndicator::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(CorrelationTrendIndicator::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let cti = CorrelationTrendIndicator::new(20).unwrap();
assert_eq!(cti.period(), 20);
assert_eq!(cti.warmup_period(), 20);
assert_eq!(cti.name(), "CorrelationTrendIndicator");
assert!(!cti.is_ready());
assert_eq!(cti.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn clean_uptrend_is_one() {
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
let last = cti
.batch(&(0..40).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn clean_downtrend_is_minus_one() {
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
let last = cti
.batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_window_is_zero() {
let mut cti = CorrelationTrendIndicator::new(8).unwrap();
let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut cti = CorrelationTrendIndicator::new(20).unwrap();
for v in cti
.batch(
&(0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
let ready = cti
.batch(&[1.0, 2.0, 3.0, 4.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(cti.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
cti.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(cti.is_ready());
cti.reset();
assert!(!cti.is_ready());
assert_eq!(cti.value(), None);
assert_eq!(cti.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs);
let mut b = CorrelationTrendIndicator::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -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);
}
}
+158 -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,90 @@ impl Ema {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.state
if self.seeded {
Some(self.current)
} else {
None
}
}
/// Whether the EMA has seen no input yet (neither seeded nor mid-warmup).
/// Lets composite indicators (e.g. MACD) decide if a fast batch path is safe.
pub(crate) fn is_fresh(&self) -> bool {
!self.seeded && self.warmup_buf.is_empty()
}
/// Force the EMA into its seeded steady state with `current` as the latest
/// value. Used by composite fused batch paths (MACD) to leave each sub-EMA
/// where a per-tick `update` replay would, so a later `update` continues
/// correctly. The post-seed recurrence never re-reads `warmup_buf`, so it is
/// left as-is.
pub(crate) fn seed_to(&mut self, current: f64) {
self.current = current;
self.seeded = true;
}
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
///
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
/// default via inherent-method resolution. For a fresh indicator over an
/// all-finite slice it runs the seed (mean of the first `period`) once and
/// then the bare `alpha * x + (1 - alpha) * prev` recurrence in a tight loop
/// with no per-element `is_finite`/`seeded` branch and no `Option` — yet uses
/// the identical `mul_add`, so the result is *bit-for-bit* equal to replaying
/// `update`. Any other state, or a non-finite element, defers to the exact
/// `update` replay.
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
let p = self.period;
if self.seeded || !self.warmup_buf.is_empty() || !inputs.iter().all(|x| x.is_finite()) {
return inputs
.iter()
.map(|&x| self.update(x).unwrap_or(f64::NAN))
.collect();
}
let n = inputs.len();
if n < p {
// Not enough to seed; mirror `update` stashing inputs for warmup.
self.warmup_buf.extend_from_slice(inputs);
return vec![f64::NAN; n];
}
// Warmup `[0, p-1)` is `NaN`; values from the seed on are pushed once each.
let mut out = vec![f64::NAN; p - 1];
out.reserve(n - (p - 1));
let seed = inputs[..p].iter().copied().sum::<f64>() / p as f64;
let mut cur = seed;
out.push(seed);
let (alpha, oma) = (self.alpha, self.one_minus_alpha);
for &x in &inputs[p..] {
cur = alpha.mul_add(x, oma * cur);
out.push(cur);
}
// Leave state exactly where `update` would: seeded on `current`, with the
// first `period` inputs retained in `warmup_buf` (never cleared post-seed).
self.current = cur;
self.seeded = true;
self.warmup_buf.extend_from_slice(&inputs[..p]);
out
}
/// 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 +191,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 +207,7 @@ impl Indicator for Ema {
}
fn is_ready(&self) -> bool {
self.state.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -268,6 +350,71 @@ mod tests {
assert_eq!(ema.update(f64::INFINITY), before);
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
fn ema_replay(period: usize, series: &[f64]) -> Vec<f64> {
let mut e = Ema::new(period).unwrap();
series
.iter()
.map(|&x| e.update(x).unwrap_or(f64::NAN))
.collect()
}
#[test]
fn batch_nan_fast_path_is_bit_identical() {
let series: Vec<f64> = (0..300)
.map(|i| (f64::from(i) * 0.25).cos() * 8.0 + 40.0)
.collect();
let mut ema = Ema::new(14).unwrap();
let got = ema.batch_nan(&series);
assert!(bits_eq(&got, &ema_replay(14, &series)));
let mut ref_ema = Ema::new(14).unwrap();
for &x in &series {
ref_ema.update(x);
}
assert_eq!(ema.update(7.5), ref_ema.update(7.5));
}
#[test]
fn batch_nan_falls_back_on_non_finite() {
let series = [1.0, 2.0, 3.0, f64::INFINITY, 5.0, 6.0, 7.0];
let mut ema = Ema::new(3).unwrap();
assert!(bits_eq(&ema.batch_nan(&series), &ema_replay(3, &series)));
}
#[test]
fn batch_nan_falls_back_when_warming() {
let mut ema = Ema::new(3).unwrap();
ema.update(10.0); // mid-warmup: warmup_buf non-empty, not seeded
let series = [1.0, 2.0, 3.0, 4.0];
let mut ref_ema = Ema::new(3).unwrap();
ref_ema.update(10.0);
let want: Vec<f64> = series
.iter()
.map(|&x| ref_ema.update(x).unwrap_or(f64::NAN))
.collect();
assert!(bits_eq(&ema.batch_nan(&series), &want));
}
#[test]
fn batch_nan_sub_period_slice_stays_unseeded() {
let series = [1.0, 2.0];
let mut ema = Ema::new(5).unwrap();
let got = ema.batch_nan(&series);
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 2);
assert!(!ema.is_ready());
// Warmup state was stashed: feeding the rest seeds exactly as a full stream.
assert!(bits_eq(
&[ema.update(3.0).unwrap_or(f64::NAN)],
&[ema_replay(5, &[1.0, 2.0, 3.0])[2]]
));
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
@@ -0,0 +1,269 @@
//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1].
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator
/// that swings cleanly in `[1, +1]` regardless of price amplitude.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12):
///
/// ```text
/// alpha1 = (1 sin(2π/hp_period)) / cos(2π/hp_period)
/// HP_t = 0.5·(1 + alpha1)·(price_t price_{t1}) + alpha1·HP_{t1} (one-pole highpass)
/// Filt = SuperSmoother(HP, ssf_length)
/// Wave = (Filt_t + Filt_{t1} + Filt_{t2}) / 3
/// Pwr = (Filt_t² + Filt_{t1}² + Filt_{t2}²) / 3
/// EBSW = Wave / sqrt(Pwr)
/// ```
///
/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
/// cycle by its RMS power normalises the amplitude, so the output reads like a
/// clean sine wave bounded in `[1, +1]` whatever the instrument. Unlike the
/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
/// components from the Hilbert transform and can whip in trends), the EBSW stays
/// well-behaved and is read directly: crossing up through `0`/`0.9` is a buy
/// cue, crossing down through `0`/`+0.9` a sell cue.
///
/// The first value lands once three SuperSmoothed samples exist
/// (`warmup_period == 3`). Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EvenBetterSinewave};
///
/// let mut indicator = EvenBetterSinewave::new(40, 10).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 EvenBetterSinewave {
hp_period: usize,
ssf_length: usize,
alpha1: f64,
smoother: SuperSmoother,
prev_price: Option<f64>,
hp: f64,
filt1: Option<f64>,
filt2: Option<f64>,
filt3: Option<f64>,
last: Option<f64>,
}
impl EvenBetterSinewave {
/// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
/// `ssf_length`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either argument is `0`.
pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
if hp_period == 0 || ssf_length == 0 {
return Err(Error::PeriodZero);
}
let w = 2.0 * PI / hp_period as f64;
let alpha1 = (1.0 - w.sin()) / w.cos();
Ok(Self {
hp_period,
ssf_length,
alpha1,
smoother: SuperSmoother::new(ssf_length)?,
prev_price: None,
hp: 0.0,
filt1: None,
filt2: None,
filt3: None,
last: None,
})
}
/// Configured `(hp_period, ssf_length)`.
pub const fn params(&self) -> (usize, usize) {
(self.hp_period, self.ssf_length)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for EvenBetterSinewave {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let hp = match self.prev_price {
Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp,
None => 0.0,
};
self.prev_price = Some(price);
self.hp = hp;
let filt = self.smoother.update(hp)?;
// Shift the three-deep filter buffer.
self.filt3 = self.filt2;
self.filt2 = self.filt1;
self.filt1 = Some(filt);
let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
return None;
};
let wave = (f1 + f2 + f3) / 3.0;
let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
let ebsw = if pwr > 0.0 {
(wave / pwr.sqrt()).clamp(-1.0, 1.0)
} else {
0.0
};
self.last = Some(ebsw);
Some(ebsw)
}
fn reset(&mut self) {
self.smoother.reset();
self.prev_price = None;
self.hp = 0.0;
self.filt1 = None;
self.filt2 = None;
self.filt3 = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"EvenBetterSinewave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_params() {
assert!(matches!(
EvenBetterSinewave::new(0, 10),
Err(Error::PeriodZero)
));
assert!(matches!(
EvenBetterSinewave::new(40, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let e = EvenBetterSinewave::new(40, 10).unwrap();
assert_eq!(e.params(), (40, 10));
assert_eq!(e.warmup_period(), 3);
assert_eq!(e.name(), "EvenBetterSinewave");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
let xs: Vec<f64> = (0..12)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
.collect();
let out = e.batch(&xs);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn output_in_range() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
.collect();
for v in e.batch(&xs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
}
}
#[test]
fn cyclic_input_swings_both_signs() {
let mut e = EvenBetterSinewave::new(30, 8).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
.collect();
let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
e.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = e.value();
assert_eq!(e.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
e.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
let mut b = EvenBetterSinewave::new(40, 10).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_input_yields_zero_power() {
// A constant series drives the highpass/smoother outputs to zero, so the
// signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
let flat = [100.0_f64; 200];
let last = EvenBetterSinewave::new(40, 10)
.unwrap()
.batch(&flat)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
}
@@ -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,215 @@
//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price
/// series, leaving the higher-frequency cyclic and noise content.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// a = 0.707 · 2π / period
/// alpha1 = (cos(a) + sin(a) 1) / cos(a)
/// HP_t = (1 alpha1/2)² · (price_t 2·price_{t1} + price_{t2})
/// + 2·(1 alpha1)·HP_{t1} (1 alpha1)²·HP_{t2}
/// ```
///
/// A highpass filter is the complement of a smoother: where a lowpass keeps the
/// trend, the highpass keeps everything *faster* than the cutoff `period`. The
/// two-pole design gives a steep roll-off so frequencies below the cutoff are
/// firmly removed, detrending the series into a zero-mean wave. This differs from
/// the [`Decycler`](crate::Decycler), which is `price highpass` (the *trend* that
/// remains); the highpass is the cyclic part that the decycler discards.
///
/// The recursion needs two prior prices and two prior outputs; until then it emits
/// `0`, so `warmup_period` is `1`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HighpassFilter};
///
/// let mut indicator = HighpassFilter::new(48).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin() * 3.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HighpassFilter {
period: usize,
alpha1: f64,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
hp1: f64,
hp2: f64,
last: Option<f64>,
}
impl HighpassFilter {
/// Construct a two-pole highpass filter with the given cutoff `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let a = 0.707 * 2.0 * PI / period as f64;
let alpha1 = (a.cos() + a.sin() - 1.0) / a.cos();
Ok(Self {
period,
alpha1,
prev_price_1: None,
prev_price_2: None,
hp1: 0.0,
hp2: 0.0,
last: None,
})
}
/// Configured cutoff 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 HighpassFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let hp = match (self.prev_price_1, self.prev_price_2) {
(Some(p1), Some(p2)) => {
let one_minus = 1.0 - self.alpha1;
let half = 1.0 - self.alpha1 / 2.0;
half * half * (price - 2.0 * p1 + p2) + 2.0 * one_minus * self.hp1
- one_minus * one_minus * self.hp2
}
_ => 0.0,
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.hp2 = self.hp1;
self.hp1 = hp;
self.last = Some(hp);
Some(hp)
}
fn reset(&mut self) {
self.prev_price_1 = None;
self.prev_price_2 = None;
self.hp1 = 0.0;
self.hp2 = 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 {
"HighpassFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(HighpassFilter::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let hp = HighpassFilter::new(48).unwrap();
assert_eq!(hp.period(), 48);
assert_eq!(hp.warmup_period(), 1);
assert_eq!(hp.name(), "HighpassFilter");
assert!(!hp.is_ready());
assert_eq!(hp.value(), None);
}
#[test]
fn first_bars_are_zero() {
let mut hp = HighpassFilter::new(48).unwrap();
assert_eq!(hp.update(100.0), Some(0.0));
assert_eq!(hp.update(101.0), Some(0.0));
assert!(hp.is_ready());
}
#[test]
fn constant_input_stays_zero() {
let mut hp = HighpassFilter::new(48).unwrap();
for v in hp.batch(&[50.0; 200]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn pure_trend_is_attenuated() {
// A straight ramp is low-frequency -> the highpass should drive its
// output small after warmup (the trend is removed).
let mut hp = HighpassFilter::new(20).unwrap();
let out: Vec<f64> = hp
.batch(&(0..400).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.skip(200)
.collect();
for v in out {
assert!(v.abs() < 5.0, "trend should be attenuated, got {v}");
}
}
#[test]
fn ignores_non_finite() {
let mut hp = HighpassFilter::new(48).unwrap();
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = hp.value();
assert_eq!(hp.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut hp = HighpassFilter::new(48).unwrap();
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(hp.is_ready());
hp.reset();
assert!(!hp.is_ready());
assert_eq!(hp.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + f64::from(i) + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = HighpassFilter::new(48).unwrap().batch(&xs);
let mut b = HighpassFilter::new(48).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,185 @@
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
/// bar's volume by where the close lands inside the bar's range.
///
/// ```text
/// II_t = volume * (2*close high low) / (high low) (0 if high == low)
/// III_t = III_{t1} + II_t
/// ```
///
/// The fraction `(2*close high low) / (high low)` is `+1` when the bar
/// closes on its high, `1` when it closes on its low, and `0` at the midpoint.
/// Scaling it by volume and accumulating produces a running measure of how
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
/// for institutional accumulation (rising line) or distribution (falling line).
///
/// This is the **cumulative** Intraday Intensity (the original index), not the
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
/// by a windowed sum of volume and is mathematically identical to
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
/// arbitrary; only its slope and divergences against price matter. A doji whose
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
/// already emits a value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
///
/// let mut indicator = IntradayIntensity::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct IntradayIntensity {
iii: f64,
last: Option<f64>,
}
impl IntradayIntensity {
/// Construct a new Intraday Intensity Index. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for IntradayIntensity {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let ii = if range > 0.0 {
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
} else {
0.0
};
self.iii += ii;
self.last = Some(self.iii);
Some(self.iii)
}
fn reset(&mut self) {
self.iii = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"IntradayIntensity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn accessors_and_metadata() {
let iii = IntradayIntensity::new();
assert_eq!(iii.warmup_period(), 1);
assert_eq!(iii.name(), "IntradayIntensity");
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn first_bar_emits() {
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
let mut iii = IntradayIntensity::new();
// close on the high -> +1 * volume.
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
}
#[test]
fn close_on_high_adds_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
}
#[test]
fn close_on_low_subtracts_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
}
#[test]
fn close_at_midpoint_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn zero_range_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn accumulates_across_bars() {
let mut iii = IntradayIntensity::new();
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut iii = IntradayIntensity::new();
iii.batch(&[
candle(110.0, 100.0, 108.0, 1.0),
candle(110.0, 100.0, 102.0, 1.0),
]);
assert!(iii.is_ready());
iii.reset();
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
})
.collect();
let batch = IntradayIntensity::new().batch(&candles);
let mut b = IntradayIntensity::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,263 @@
//! Jarque-Bera — a normality-test statistic on a rolling window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's
/// distribution departs from normal, via its **skewness** and **excess
/// kurtosis**.
///
/// ```text
/// S = skewness = m3 / m2^(3/2)
/// K = excess kurtosis = m4 / m2² 3
/// JB = (period / 6) · ( S² + K²/4 )
/// ```
///
/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the
/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so
/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero
/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of
/// normality `JB` is asymptotically χ² with two degrees of freedom, so values
/// above roughly `6` reject normality at the 95% level — a useful streaming flag
/// for fat-tail / crash-risk regimes in a return series.
///
/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`)
/// returns `0`. The first value lands after `period` inputs; each `update`
/// recomputes the four moments over the window in O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, JarqueBera};
///
/// let mut indicator = JarqueBera::new(50).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update((f64::from(i) * 0.3).sin());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct JarqueBera {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl JarqueBera {
/// Construct a rolling Jarque-Bera over `period` values.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on
/// fewer than four points).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 4 {
return Err(Error::InvalidPeriod {
message: "Jarque-Bera needs period >= 4",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn compute(&self) -> f64 {
let n = self.period as f64;
let mean = self.window.iter().sum::<f64>() / n;
let mut m2 = 0.0;
let mut m3 = 0.0;
let mut m4 = 0.0;
for &v in &self.window {
let d = v - mean;
let d2 = d * d;
m2 += d2;
m3 += d2 * d;
m4 += d2 * d2;
}
m2 /= n;
m3 /= n;
m4 /= n;
if m2 == 0.0 {
return 0.0;
}
let skew = m3 / m2.powf(1.5);
let excess_kurt = m4 / (m2 * m2) - 3.0;
(n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0)
}
}
impl Indicator for JarqueBera {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"JarqueBera"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_period() {
assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero)));
assert!(matches!(
JarqueBera::new(3),
Err(Error::InvalidPeriod { .. })
));
assert!(JarqueBera::new(4).is_ok());
}
#[test]
fn accessors_and_metadata() {
let jb = JarqueBera::new(50).unwrap();
assert_eq!(jb.period(), 50);
assert_eq!(jb.warmup_period(), 50);
assert_eq!(jb.name(), "JarqueBera");
assert!(!jb.is_ready());
assert_eq!(jb.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut jb = JarqueBera::new(4).unwrap();
let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn constant_window_is_zero() {
let mut jb = JarqueBera::new(8).unwrap();
let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_is_non_negative() {
let mut jb = JarqueBera::new(30).unwrap();
for v in jb
.batch(
&(0..200)
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!(v >= 0.0, "JB must be non-negative, got {v}");
}
}
#[test]
fn skewed_window_exceeds_symmetric() {
// A symmetric window vs. one with a heavy outlier (high skew + kurtosis).
let symmetric: Vec<f64> = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0];
let skewed: Vec<f64> = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0];
let jb_sym = JarqueBera::new(8)
.unwrap()
.batch(&symmetric)
.into_iter()
.flatten()
.last()
.unwrap();
let jb_skew = JarqueBera::new(8)
.unwrap()
.batch(&skewed)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(
jb_skew > jb_sym,
"skewed ({jb_skew}) should exceed symmetric ({jb_sym})"
);
}
#[test]
fn ignores_non_finite() {
let mut jb = JarqueBera::new(4).unwrap();
let ready = jb
.batch(&[1.0, 2.0, 3.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(jb.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut jb = JarqueBera::new(4).unwrap();
jb.batch(&[1.0, 2.0, 3.0, 5.0]);
assert!(jb.is_ready());
jb.reset();
assert!(!jb.is_ready());
assert_eq!(jb.value(), None);
assert_eq!(jb.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = JarqueBera::new(30).unwrap().batch(&xs);
let mut b = JarqueBera::new(30).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).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,296 @@
//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// `+1` / `0` / `-1` sign of `a b`.
fn sign(a: f64, b: f64) -> i32 {
if a > b {
1
} else if a < b {
-1
} else {
0
}
}
/// Kendall's tau-b — a rank correlation between two synchronised series based on
/// the balance of **concordant** and **discordant** pairs, with a tie correction.
///
/// ```text
/// over all pairs (i < j) in the window:
/// concordant if (x_j x_i) and (y_j y_i) share a sign
/// discordant if they have opposite signs
/// tie_x / tie_y if the respective difference is zero
/// n0 = N(N1)/2
/// tau_b = (n_concordant n_discordant) / sqrt((n0 tie_x)(n0 tie_y))
/// ```
///
/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear*
/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates
/// ranks via their differences, Kendall's tau counts how often the two series move
/// the **same direction** between every pair of observations. It is the most
/// robust of the three to outliers and to non-linear-but-monotonic
/// relationships, and the tau-b form corrects for ties so repeated values do not
/// bias it. The output is in `[1, +1]`: `+1` perfectly concordant, `1`
/// perfectly discordant, `0` no monotonic association.
///
/// The window holds the last `period` pairs and is recomputed each bar in
/// O(`period²`). A window with no untied pairs on one side returns `0`. The first
/// value lands after `period` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, KendallTau};
///
/// let mut indicator = KendallTau::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let x = f64::from(i);
/// last = indicator.update((x, 2.0 * x)); // perfectly concordant
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct KendallTau {
period: usize,
window: VecDeque<(f64, f64)>,
last: Option<f64>,
}
impl KendallTau {
/// Construct a rolling Kendall's tau-b over `period` pairs.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at
/// least two pairs).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "Kendall tau needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured window of pairs.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn compute(&self) -> f64 {
let pairs: Vec<(f64, f64)> = self.window.iter().copied().collect();
let len = pairs.len();
let mut concordant: i64 = 0;
let mut discordant: i64 = 0;
let mut tie_x: i64 = 0;
let mut tie_y: i64 = 0;
for i in 0..len {
for j in (i + 1)..len {
let sx = sign(pairs[j].0, pairs[i].0);
let sy = sign(pairs[j].1, pairs[i].1);
if sx == 0 {
tie_x += 1;
}
if sy == 0 {
tie_y += 1;
}
let prod = sx * sy;
if prod > 0 {
concordant += 1;
} else if prod < 0 {
discordant += 1;
}
}
}
let n0 = (len * (len - 1) / 2) as f64;
let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt();
if denom == 0.0 {
return 0.0;
}
((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0)
}
}
impl Indicator for KendallTau {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"KendallTau"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(matches!(
KendallTau::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(KendallTau::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let k = KendallTau::new(20).unwrap();
assert_eq!(k.period(), 20);
assert_eq!(k.warmup_period(), 20);
assert_eq!(k.name(), "KendallTau");
assert!(!k.is_ready());
assert_eq!(k.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut k = KendallTau::new(4).unwrap();
let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn monotone_increasing_is_one() {
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0))
.collect();
let last = KendallTau::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn monotone_decreasing_is_minus_one() {
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| (f64::from(i), -3.0 * f64::from(i)))
.collect();
let last = KendallTau::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn constant_channel_yields_zero() {
// y constant -> every y-difference is a tie -> denom 0 -> 0.
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
let last = KendallTau::new(8)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|i| {
let t = f64::from(i);
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
})
.collect();
for v in KendallTau::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
{
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn reset_clears_state() {
let mut k = KendallTau::new(4).unwrap();
k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.value(), None);
assert_eq!(k.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin(), (t * 0.5).cos())
})
.collect();
let batch = KendallTau::new(14).unwrap().batch(&pairs);
let mut b = KendallTau::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ties_are_corrected() {
// Tied x values (points 0 and 1) and tied y values (points 1 and 2)
// exercise the tie_x / tie_y correction counters.
let mut k = KendallTau::new(4).unwrap();
assert_eq!(k.update((1.0, 1.0)), None);
assert_eq!(k.update((1.0, 2.0)), None);
assert_eq!(k.update((2.0, 2.0)), None);
let v = k.update((3.0, 3.0)).unwrap();
assert!((-1.0..=1.0).contains(&v), "got {v}");
}
}
+183
View File
@@ -86,6 +86,116 @@ impl MacdIndicator {
pub const fn value(&self) -> Option<MacdOutput> {
self.last
}
/// Vectorized flat batch for bindings: `n * 3` values laid out as
/// `[macd, signal, histogram]` per input row, warmup rows all `NaN`.
///
/// For a fresh, all-finite slice long enough for a full output it runs the
/// fast EMA, slow EMA and signal EMA as three recurrences fused into a single
/// pass with one allocation — no `Option` per tick, no per-EMA intermediate
/// buffers, identical SMA-mean seeds (division) and `mul_add` recurrences. The
/// result is *bit-for-bit* equal to replaying `update`. Anything else (not
/// fresh, non-finite, or too short to emit) defers to the exact `update`
/// replay.
///
/// Separate from the trait [`batch`](crate::BatchExt::batch), which stays a
/// bit-identical `update` replay; only the bindings call this.
pub fn batch_macd(&mut self, inputs: &[f64]) -> Vec<f64> {
let n = inputs.len();
let (fp, sp, gp) = (self.fast_period, self.slow_period, self.signal_period);
// First full output needs the slow EMA seeded (index sp-1) plus gp signal
// values: index sp + gp - 2. Below that, or non-fresh/non-finite, replay.
if self.last.is_some()
|| !self.fast.is_fresh()
|| !self.slow.is_fresh()
|| !self.signal_ema.is_fresh()
|| n < sp + gp - 1
|| !inputs.iter().all(|x| x.is_finite())
{
let mut out = vec![f64::NAN; n * 3];
for (i, &x) in inputs.iter().enumerate() {
if let Some(o) = self.update(x) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
return out;
}
// Pre-sized output: warmup rows stay NaN, full-output rows are written in
// place by index — no per-row `push` length/capacity check.
let mut out = vec![f64::NAN; n * 3];
let (fa, fo) = (self.fast.alpha(), 1.0 - self.fast.alpha());
let (sa, so) = (self.slow.alpha(), 1.0 - self.slow.alpha());
let (ga, go) = (self.signal_ema.alpha(), 1.0 - self.signal_ema.alpha());
let (fp_f, sp_f, gp_f) = (fp as f64, sp as f64, gp as f64);
let (mut fast_val, mut slow_val, mut sig) = (0.0_f64, 0.0_f64, 0.0_f64);
let (mut fsum, mut ssum, mut gsum) = (0.0_f64, 0.0_f64, 0.0_f64);
let mut sig_count = 0usize; // signal-EMA seed progress (raw MACD values seen)
let mut sig_seeded = false;
let mut last = MacdOutput {
macd: 0.0,
signal: 0.0,
histogram: 0.0,
};
for (i, &x) in inputs.iter().enumerate() {
// Fast EMA: SMA-seeded at index fp-1, then recurrence.
if i < fp {
fsum += x;
if i == fp - 1 {
fast_val = fsum / fp_f;
}
} else {
fast_val = fa.mul_add(x, fo * fast_val);
}
// Slow EMA: SMA-seeded at index sp-1, then recurrence.
if i < sp {
ssum += x;
if i == sp - 1 {
slow_val = ssum / sp_f;
}
} else {
slow_val = sa.mul_add(x, so * slow_val);
}
if i + 1 < sp {
continue; // slow EMA not seeded yet → no raw MACD line
}
let macd = fast_val - slow_val;
// Signal EMA over the MACD line: SMA-seeded over its first gp values.
let signal = if sig_seeded {
sig = ga.mul_add(macd, go * sig);
sig
} else {
gsum += macd;
sig_count += 1;
if sig_count < gp {
continue; // signal EMA still seeding → no full output
}
sig = gsum / gp_f;
sig_seeded = true;
sig
};
let histogram = macd - signal;
out[i * 3] = macd;
out[i * 3 + 1] = signal;
out[i * 3 + 2] = histogram;
last = MacdOutput {
macd,
signal,
histogram,
};
}
// Leave every sub-EMA and `last` where a full `update` replay would.
self.fast.seed_to(fast_val);
self.slow.seed_to(slow_val);
self.signal_ema.seed_to(sig);
self.last = Some(last);
out
}
}
impl Indicator for MacdIndicator {
@@ -256,6 +366,79 @@ mod tests {
assert_eq!(macd.update(1.0), None);
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
/// Flat `n*3` `[macd, signal, histogram]` replay of `update`.
fn macd_replay(series: &[f64]) -> Vec<f64> {
let mut m = MacdIndicator::classic();
let mut out = Vec::with_capacity(series.len() * 3);
for &x in series {
match m.update(x) {
Some(o) => out.extend_from_slice(&[o.macd, o.signal, o.histogram]),
None => out.extend_from_slice(&[f64::NAN; 3]),
}
}
out
}
#[test]
fn batch_macd_fast_path_is_bit_identical() {
let series: Vec<f64> = (0..300)
.map(|i| (f64::from(i) * 0.4).cos() * 10.0 + 100.0)
.collect();
let mut macd = MacdIndicator::classic();
let got = macd.batch_macd(&series);
assert!(bits_eq(&got, &macd_replay(&series)));
// Sub-EMA + last state left where the replay would: continued update agrees.
let mut ref_macd = MacdIndicator::classic();
for &x in &series {
ref_macd.update(x);
}
let (a, b) = (macd.update(101.0), ref_macd.update(101.0));
assert_eq!(a.is_some(), b.is_some());
assert_relative_eq!(a.unwrap().macd, b.unwrap().macd, epsilon = 1e-12);
}
#[test]
fn batch_macd_falls_back_on_non_finite() {
let mut series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
series[40] = f64::NAN;
let mut macd = MacdIndicator::classic();
assert!(bits_eq(&macd.batch_macd(&series), &macd_replay(&series)));
}
#[test]
fn batch_macd_falls_back_when_not_fresh() {
let series: Vec<f64> = (0..60).map(|i| f64::from(i) + 100.0).collect();
let mut macd = MacdIndicator::classic();
macd.update(50.0);
let mut ref_macd = MacdIndicator::classic();
ref_macd.update(50.0);
let mut want = Vec::new();
for &x in &series {
match ref_macd.update(x) {
Some(o) => want.extend_from_slice(&[o.macd, o.signal, o.histogram]),
None => want.extend_from_slice(&[f64::NAN; 3]),
}
}
assert!(bits_eq(&macd.batch_macd(&series), &want));
}
#[test]
fn batch_macd_too_short_for_output_falls_back() {
// n < slow + signal - 1 (= 34): no full output, routed to the replay.
let series: Vec<f64> = (0..20).map(|i| f64::from(i) + 100.0).collect();
let mut macd = MacdIndicator::classic();
let got = macd.batch_macd(&series);
assert!(bits_eq(&got, &macd_replay(&series)));
assert!(got.iter().all(|x| x.is_nan()));
}
#[test]
fn ignores_non_finite_input() {
let mut macd = MacdIndicator::classic();
@@ -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());
}
}
+154 -1
View File
@@ -16,8 +16,10 @@ mod acceleration_bands;
mod accelerator_oscillator;
mod ad_oscillator;
mod ad_volume_line;
mod adaptive_cci;
mod adaptive_cycle;
mod adaptive_laguerre_filter;
mod adaptive_rsi;
mod adl;
mod advance_block;
mod advance_decline;
@@ -30,27 +32,34 @@ mod alpha;
mod amihud_illiquidity;
mod anchored_rsi;
mod anchored_vwap;
mod andrews_pitchfork;
mod apo;
mod aroon;
mod aroon_oscillator;
mod atr;
mod atr_bands;
mod atr_ratchet;
mod atr_trailing_stop;
mod auto_fib;
mod autocorrelation;
mod autocorrelation_periodogram;
mod average_daily_range;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod bandpass_filter;
mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod better_volume;
mod bipower_variation;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
mod bomar_bands;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
@@ -60,6 +69,7 @@ mod calmar_ratio;
mod camarilla_pivots;
mod cci;
mod center_of_gravity;
mod central_pivot_range;
mod cfo;
mod chaikin_oscillator;
mod chaikin_volatility;
@@ -77,6 +87,7 @@ mod concealing_baby_swallow;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod correlation_trend_indicator;
mod counterattack;
mod crab;
mod cumulative_volume_index;
@@ -113,11 +124,14 @@ mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod elder_ray;
mod elder_safezone;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
mod even_better_sinewave;
mod evening_doji_star;
mod evwma;
mod ewma_volatility;
mod expectancy;
mod falling_three_methods;
mod fama;
@@ -143,6 +157,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;
@@ -159,6 +174,7 @@ mod heikin_ashi;
mod high_low_index;
mod high_low_range;
mod high_wave;
mod highpass_filter;
mod hikkake;
mod hikkake_modified;
mod hilbert_dominant_cycle;
@@ -179,18 +195,22 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_intensity;
mod intraday_momentum_index;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jarque_bera;
mod jma;
mod jump_indicator;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
mod kase_devstop;
mod kase_permission_stochastic;
mod kelly_criterion;
mod keltner;
mod kendall_tau;
mod kicking;
mod kicking_by_length;
mod kst;
@@ -226,6 +246,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 +255,14 @@ 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 murrey_math_lines;
mod natr;
mod new_highs_new_lows;
mod nrtr;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
@@ -265,6 +289,7 @@ mod percent_b;
mod percentage_trailing_stop;
mod pgo;
mod piercing_dark_cloud;
mod pivot_reversal;
mod plus_di;
mod plus_dm;
mod pmo;
@@ -273,16 +298,20 @@ 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;
mod realized_volatility;
mod recovery_factor;
mod rectangle_range;
mod reflex;
mod regime_label;
mod relative_strength_ab;
mod renko_bars;
@@ -299,6 +328,7 @@ mod roll_measure;
mod rolling_correlation;
mod rolling_covariance;
mod rolling_iqr;
mod rolling_min_max_scaler;
mod rolling_percentile_rank;
mod rolling_quantile;
mod roofing_filter;
@@ -307,12 +337,14 @@ mod rsx;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sample_entropy;
mod sar_ext;
mod seasonal_z_score;
mod separating_lines;
mod session_high_low;
mod session_range;
mod session_vwap;
mod shannon_entropy;
mod shark;
mod sharpe_ratio;
mod shooting_star;
@@ -347,18 +379,25 @@ mod t3;
mod taker_buy_sell_ratio;
mod takuri;
mod tasuki_gap;
mod td_camouflage;
mod td_clop;
mod td_clopwin;
mod td_combo;
mod td_countdown;
mod td_demarker;
mod td_differential;
mod td_dwave;
mod td_lines;
mod td_moving_average;
mod td_open;
mod td_pressure;
mod td_propulsion;
mod td_range_projection;
mod td_rei;
mod td_risk_level;
mod td_sequential;
mod td_setup;
mod td_trap;
mod tema;
mod term_structure_basis;
mod three_drives;
@@ -370,11 +409,14 @@ mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod time_based_stop;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trade_volume_index;
mod trend_label;
mod trend_strength_index;
mod trendflex;
mod treynor_ratio;
mod triangle;
mod trima;
@@ -390,11 +432,13 @@ mod ttm_squeeze;
mod ttm_trend;
mod turn_of_month;
mod tweezer;
mod twiggs_money_flow;
mod two_crows;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod unique_three_river;
mod universal_oscillator;
mod up_down_volume_ratio;
mod upside_gap_three_methods;
mod upside_gap_two_crows;
@@ -404,10 +448,16 @@ mod variance;
mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volatility_cone;
mod volatility_of_volatility;
mod volatility_ratio;
mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod volume_rsi;
mod volume_weighted_macd;
mod volume_weighted_sr;
mod vortex;
mod vpin;
mod vpt;
@@ -415,6 +465,7 @@ mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod vzo;
mod wad;
mod wave_pm;
mod wave_trend;
mod wedge;
@@ -439,8 +490,10 @@ pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use ad_volume_line::AdVolumeLine;
pub use adaptive_cci::AdaptiveCci;
pub use adaptive_cycle::AdaptiveCycle;
pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter;
pub use adaptive_rsi::AdaptiveRsi;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
@@ -453,27 +506,34 @@ pub use alpha::Alpha;
pub use amihud_illiquidity::AmihudIlliquidity;
pub use anchored_rsi::AnchoredRsi;
pub use anchored_vwap::AnchoredVwap;
pub use andrews_pitchfork::{AndrewsPitchfork, AndrewsPitchforkOutput};
pub use apo::Apo;
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;
pub use autocorrelation_periodogram::AutocorrelationPeriodogram;
pub use average_daily_range::AverageDailyRange;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use bandpass_filter::BandpassFilter;
pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use better_volume::BetterVolume;
pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use bomar_bands::{BomarBands, BomarBandsOutput};
pub use breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
@@ -483,6 +543,7 @@ pub use calmar_ratio::CalmarRatio;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
pub use cci::Cci;
pub use center_of_gravity::CenterOfGravity;
pub use central_pivot_range::{CentralPivotRange, CentralPivotRangeOutput};
pub use cfo::Cfo;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chaikin_volatility::ChaikinVolatility;
@@ -500,6 +561,7 @@ pub use concealing_baby_swallow::ConcealingBabySwallow;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use correlation_trend_indicator::CorrelationTrendIndicator;
pub use counterattack::Counterattack;
pub use crab::Crab;
pub use cumulative_volume_index::CumulativeVolumeIndex;
@@ -536,11 +598,14 @@ 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 even_better_sinewave::EvenBetterSinewave;
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 +631,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};
@@ -582,6 +648,7 @@ pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_low_index::HighLowIndex;
pub use high_low_range::HighLowRange;
pub use high_wave::HighWave;
pub use highpass_filter::HighpassFilter;
pub use hikkake::Hikkake;
pub use hikkake_modified::HikkakeModified;
pub use hilbert_dominant_cycle::HilbertDominantCycle;
@@ -602,18 +669,22 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_intensity::IntradayIntensity;
pub use intraday_momentum_index::IntradayMomentumIndex;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jarque_bera::JarqueBera;
pub use jma::Jma;
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};
pub use kendall_tau::KendallTau;
pub use kicking::Kicking;
pub use kicking_by_length::KickingByLength;
pub use kst::{Kst, KstOutput};
@@ -649,6 +720,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 +729,14 @@ 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 murrey_math_lines::{MurreyMathLines, MurreyMathLinesOutput};
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;
@@ -688,6 +763,7 @@ pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use pivot_reversal::PivotReversal;
pub use plus_di::PlusDi;
pub use plus_dm::PlusDm;
pub use pmo::Pmo;
@@ -696,16 +772,20 @@ 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;
pub use realized_volatility::RealizedVolatility;
pub use recovery_factor::RecoveryFactor;
pub use rectangle_range::RectangleRange;
pub use reflex::Reflex;
pub use regime_label::RegimeLabel;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_bars::{RenkoBars, RenkoBrick};
@@ -722,6 +802,7 @@ pub use roll_measure::RollMeasure;
pub use rolling_correlation::RollingCorrelation;
pub use rolling_covariance::RollingCovariance;
pub use rolling_iqr::RollingIqr;
pub use rolling_min_max_scaler::RollingMinMaxScaler;
pub use rolling_percentile_rank::RollingPercentileRank;
pub use rolling_quantile::RollingQuantile;
pub use roofing_filter::RoofingFilter;
@@ -730,12 +811,14 @@ pub use rsx::Rsx;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sample_entropy::SampleEntropy;
pub use sar_ext::SarExt;
pub use seasonal_z_score::SeasonalZScore;
pub use separating_lines::SeparatingLines;
pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
pub use session_range::{SessionRange, SessionRangeOutput};
pub use session_vwap::SessionVwap;
pub use shannon_entropy::ShannonEntropy;
pub use shark::Shark;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
@@ -770,18 +853,25 @@ pub use t3::T3;
pub use taker_buy_sell_ratio::TakerBuySellRatio;
pub use takuri::Takuri;
pub use tasuki_gap::TasukiGap;
pub use td_camouflage::TdCamouflage;
pub use td_clop::TdClop;
pub use td_clopwin::TdClopwin;
pub use td_combo::TdCombo;
pub use td_countdown::TdCountdown;
pub use td_demarker::TdDeMarker;
pub use td_differential::TdDifferential;
pub use td_dwave::TdDWave;
pub use td_lines::{TdLines, TdLinesOutput};
pub use td_moving_average::{TdMovingAverage, TdMovingAverageOutput};
pub use td_open::TdOpen;
pub use td_pressure::TdPressure;
pub use td_propulsion::TdPropulsion;
pub use td_range_projection::{TdRangeProjection, TdRangeProjectionOutput};
pub use td_rei::TdRei;
pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput};
pub use td_sequential::{TdSequential, TdSequentialOutput};
pub use td_setup::TdSetup;
pub use td_trap::TdTrap;
pub use tema::Tema;
pub use term_structure_basis::TermStructureBasis;
pub use three_drives::ThreeDrives;
@@ -793,11 +883,14 @@ pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_based_stop::TimeBasedStop;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trade_volume_index::TradeVolumeIndex;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
pub use trendflex::Trendflex;
pub use treynor_ratio::TreynorRatio;
pub use triangle::Triangle;
pub use trima::Trima;
@@ -813,11 +906,13 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use ttm_trend::TtmTrend;
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use twiggs_money_flow::TwiggsMoneyFlow;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use unique_three_river::UniqueThreeRiver;
pub use universal_oscillator::UniversalOscillator;
pub use up_down_volume_ratio::UpDownVolumeRatio;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
pub use upside_gap_two_crows::UpsideGapTwoCrows;
@@ -827,10 +922,16 @@ pub use variance::Variance;
pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volatility_cone::{VolatilityCone, VolatilityConeOutput};
pub use volatility_of_volatility::VolatilityOfVolatility;
pub use volatility_ratio::VolatilityRatio;
pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use volume_rsi::VolumeRsi;
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
pub use volume_weighted_sr::{VolumeWeightedSr, VolumeWeightedSrOutput};
pub use vortex::{Vortex, VortexOutput};
pub use vpin::Vpin;
pub use vpt::VolumePriceTrend;
@@ -838,6 +939,7 @@ pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wad::Wad;
pub use wave_pm::WavePm;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
@@ -1006,6 +1108,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"YangZhangVolatility",
"JumpIndicator",
"RegimeLabel",
"EwmaVolatility",
"Garch11",
"VolatilityOfVolatility",
"BipowerVariation",
"VolatilityRatio",
"VolatilityCone",
],
),
(
@@ -1022,6 +1130,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TtmSqueeze",
"FractalChaosBands",
"VwapStdDevBands",
"QuartileBands",
"BomarBands",
"MedianChannel",
"ProjectionBands",
"ProjectionOscillator",
],
),
(
@@ -1040,6 +1153,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
"KaseDevStop",
"ElderSafeZone",
"AtrRatchet",
"Nrtr",
"TimeBasedStop",
"ModifiedMaStop",
],
),
(
@@ -1064,6 +1183,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Tsv",
"Vzo",
"MarketFacilitationIndex",
"VolumeRsi",
"Wad",
"TwiggsMoneyFlow",
"TradeVolumeIndex",
"IntradayIntensity",
"BetterVolume",
"VolumeWeightedMacd",
],
),
(
@@ -1119,6 +1245,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"BodySizePct",
"WickRatio",
"HighLowRange",
"JarqueBera",
"RollingMinMaxScaler",
"ShannonEntropy",
"SampleEntropy",
"KendallTau",
],
),
(
@@ -1143,6 +1274,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"EmpiricalModeDecomposition",
"EhlersStochastic",
"InstantaneousTrendline",
"HighpassFilter",
"Reflex",
"Trendflex",
"CorrelationTrendIndicator",
"AdaptiveRsi",
"UniversalOscillator",
"AdaptiveCci",
"BandpassFilter",
"EvenBetterSinewave",
"AutocorrelationPeriodogram",
],
),
(
@@ -1155,6 +1296,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"DemarkPivots",
"WilliamsFractals",
"ZigZag",
"CentralPivotRange",
"MurreyMathLines",
"AndrewsPitchfork",
"VolumeWeightedSr",
"PivotReversal",
],
),
(
@@ -1172,6 +1318,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TdDifferential",
"TdOpen",
"TdRiskLevel",
"TdCamouflage",
"TdClop",
"TdClopwin",
"TdPropulsion",
"TdTrap",
"TdDWave",
"TdMovingAverage",
],
),
("Ichimoku & Charts", &["Ichimoku", "HeikinAshi"]),
@@ -1423,6 +1576,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, 474, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,238 @@
//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA).
use crate::error::{Error, Result};
use crate::indicators::smma::Smma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModifiedMaStopOutput {
/// The stop level (a directionally-ratcheted Modified Moving Average).
pub value: f64,
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
pub direction: f64,
}
/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving
/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's
/// favour.
///
/// ```text
/// ma = SMMA(close, period) (Modified Moving Average)
/// long: stop = max(prev_stop, ma); flip short when close < stop
/// short: stop = min(prev_stop, ma); flip long when close > stop
/// ```
///
/// The Modified Moving Average (also called the smoothed or running moving
/// average) is the slow, low-lag average Wilder used throughout his systems. Using
/// it directly as a trailing line — but **ratcheting** so the long stop never
/// falls and the short stop never rises — turns the smooth average into a stop
/// that hugs price in a trend and flips when price decisively crosses it. Because
/// the SMMA lags, the stop gives trends room while still exiting clean reversals.
///
/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ModifiedMaStop};
///
/// let mut indicator = ModifiedMaStop::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ModifiedMaStop {
smma: Smma,
period: usize,
direction: f64,
stop: f64,
last: Option<ModifiedMaStopOutput>,
}
impl ModifiedMaStop {
/// Construct a Modified-MA stop with the given SMMA `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
smma: Smma::new(period)?,
period,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured SMMA period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
self.last
}
}
impl Indicator for ModifiedMaStop {
type Input = Candle;
type Output = ModifiedMaStopOutput;
fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
let ma = self.smma.update(candle.close)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = if close >= ma { 1.0 } else { -1.0 };
self.stop = ma;
} else if self.direction > 0.0 {
self.stop = self.stop.max(ma);
if close < self.stop {
self.direction = -1.0;
self.stop = ma;
}
} else {
self.stop = self.stop.min(ma);
if close > self.stop {
self.direction = 1.0;
self.stop = ma;
}
}
let out = ModifiedMaStopOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.smma.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ModifiedMaStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(close: f64) -> Candle {
Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let m = ModifiedMaStop::new(14).unwrap();
assert_eq!(m.period(), 14);
assert_eq!(m.warmup_period(), 14);
assert_eq!(m.name(), "ModifiedMaStop");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
let out = m.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn long_stop_ratchets_up() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
let mut prev = f64::NEG_INFINITY;
for o in m.batch(&candles).into_iter().flatten() {
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
assert!(o.value >= prev, "long stop must not fall");
prev = o.value;
}
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
let mut m = ModifiedMaStop::new(5).unwrap();
let dirs: Vec<f64> = m
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut m = ModifiedMaStop::new(5).unwrap();
m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(c(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
let mut b = ModifiedMaStop::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,272 @@
//! Murrey Math Lines — the eighths grid over the recent trading range.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`MurreyMathLines`]: the nine Murrey Math levels from the bottom
/// (`mm0_8`, ultimate support) to the top (`mm8_8`, ultimate resistance).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MurreyMathLinesOutput {
/// 8/8 — ultimate resistance (top of the frame).
pub mm8_8: f64,
/// 7/8 — "weak, stall and reverse" (overbought).
pub mm7_8: f64,
/// 6/8 — upper pivot / reversal line.
pub mm6_8: f64,
/// 5/8 — top of the normal trading range.
pub mm5_8: f64,
/// 4/8 — the major pivot (mean) line.
pub mm4_8: f64,
/// 3/8 — bottom of the normal trading range.
pub mm3_8: f64,
/// 2/8 — lower pivot / reversal line.
pub mm2_8: f64,
/// 1/8 — "weak, stall and reverse" (oversold).
pub mm1_8: f64,
/// 0/8 — ultimate support (bottom of the frame).
pub mm0_8: f64,
}
/// Murrey Math Lines — T. H. Murrey's grid that divides the recent trading range
/// into eighths, each acting as support/resistance.
///
/// ```text
/// HH = highest high over `period`, LL = lowest low over `period`
/// step = (HH LL) / 8
/// mm{i}_8 = LL + i · step for i = 0..8
/// ```
///
/// Murrey Math (a Gann-derived framework) holds that price gravitates to and
/// reverses at the eighth divisions of its range. The **4/8** line is the major
/// pivot (mean); **0/8** and **8/8** are the strongest support and resistance;
/// **3/8** and **5/8** bound the "normal" trading range, while **1/8**/**7/8** are
/// the weak "stall and reverse" lines. This implementation uses the price-derived
/// eighths over a rolling high-low frame (the practical core of the method) rather
/// than Murrey's full octave-quantised frame sizing, so the levels track the
/// instrument's actual recent range.
///
/// The first value lands after `period` inputs; each `update` rescans the frame in
/// O(`period`). A degenerate flat frame (`HH == LL`) collapses every line onto the
/// price.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MurreyMathLines};
///
/// let mut indicator = MurreyMathLines::new(64).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MurreyMathLines {
period: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
last: Option<MurreyMathLinesOutput>,
}
impl MurreyMathLines {
/// Construct Murrey Math Lines over a `period`-bar high-low frame.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured frame period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<MurreyMathLinesOutput> {
self.last
}
}
impl Indicator for MurreyMathLines {
type Input = Candle;
type Output = MurreyMathLinesOutput;
fn update(&mut self, candle: Candle) -> Option<MurreyMathLinesOutput> {
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 hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let step = (hh - ll) / 8.0;
let level = |i: f64| ll + i * step;
let out = MurreyMathLinesOutput {
mm0_8: level(0.0),
mm1_8: level(1.0),
mm2_8: level(2.0),
mm3_8: level(3.0),
mm4_8: level(4.0),
mm5_8: level(5.0),
mm6_8: level(6.0),
mm7_8: level(7.0),
mm8_8: level(8.0),
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"MurreyMathLines"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64) -> Candle {
Candle::new_unchecked(low, high, low, f64::midpoint(high, low), 1_000.0, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MurreyMathLines::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let m = MurreyMathLines::new(64).unwrap();
assert_eq!(m.period(), 64);
assert_eq!(m.warmup_period(), 64);
assert_eq!(m.name(), "MurreyMathLines");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = MurreyMathLines::new(4).unwrap();
let candles: Vec<Candle> = (0..6)
.map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
.collect();
let out = m.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn eighths_are_evenly_spaced() {
// Frame [100, 180] over the window -> step = 10.
let mut m = MurreyMathLines::new(2).unwrap();
let out = m
.batch(&[c(180.0, 100.0), c(180.0, 100.0)])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.mm0_8, 100.0, epsilon = 1e-9);
assert_relative_eq!(out.mm4_8, 140.0, epsilon = 1e-9);
assert_relative_eq!(out.mm8_8, 180.0, epsilon = 1e-9);
assert_relative_eq!(out.mm1_8 - out.mm0_8, 10.0, epsilon = 1e-9);
}
#[test]
fn levels_are_ordered() {
let mut m = MurreyMathLines::new(10).unwrap();
let candles: Vec<Candle> = (0..30)
.map(|i| {
c(
110.0 + (f64::from(i) * 0.3).sin() * 8.0,
90.0 + (f64::from(i) * 0.3).cos() * 8.0,
)
})
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert!(o.mm0_8 <= o.mm4_8 && o.mm4_8 <= o.mm8_8);
assert!(o.mm3_8 <= o.mm5_8);
}
}
#[test]
fn flat_frame_collapses() {
let mut m = MurreyMathLines::new(3).unwrap();
let out = m
.batch(&[c(50.0, 50.0), c(50.0, 50.0), c(50.0, 50.0)])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.mm0_8, 50.0, epsilon = 1e-12);
assert_relative_eq!(out.mm8_8, 50.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut m = MurreyMathLines::new(4).unwrap();
m.batch(
&(0..6)
.map(|i| c(101.0 + f64::from(i), 99.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(101.0, 99.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
c(
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
90.0 + (f64::from(i) * 0.25).cos() * 9.0,
)
})
.collect();
let batch = MurreyMathLines::new(64).unwrap().batch(&candles);
let mut b = MurreyMathLines::new(64).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).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,293 @@
//! Pivot Reversal — a breakout signal off the most recent confirmed swing pivots.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Pivot Reversal — emits a reversal **breakout signal** when price closes through
/// the most recently confirmed swing pivot.
///
/// ```text
/// pivot high: a bar whose high is strictly above the `left` bars before and the
/// `right` bars after it (confirmed `right` bars late)
/// pivot low : the mirror on lows
/// signal = +1 when close crosses above the last confirmed pivot high
/// signal = 1 when close crosses below the last confirmed pivot low
/// signal = 0 otherwise
/// ```
///
/// Unlike [`WilliamsFractals`](crate::WilliamsFractals), which merely *marks* the
/// swing points, Pivot Reversal turns them into an actionable entry: once a swing
/// high is confirmed it becomes a breakout trigger — a close back above it signals
/// a bullish reversal — and likewise a close below a confirmed swing low signals a
/// bearish reversal. This is the logic of the classic "Pivot Reversal" strategy.
/// Signals fire only on the **crossing** bar, not while price sits beyond the
/// level.
///
/// The first signal can appear once `left + right + 1` bars exist (a pivot needs
/// neighbours on both sides). The output is `+1` / `0` / `1`. Each `update` is
/// O(`left + right`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PivotReversal};
///
/// let mut indicator = PivotReversal::new(2, 2).unwrap();
/// let mut fired = false;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// match indicator.update(c) {
/// Some(s) if s != 0.0 => fired = true,
/// _ => {}
/// }
/// }
/// let _ = fired;
/// ```
#[derive(Debug, Clone)]
pub struct PivotReversal {
left: usize,
right: usize,
window: VecDeque<Candle>,
pivot_high: Option<f64>,
pivot_low: Option<f64>,
prev_close: Option<f64>,
last: Option<f64>,
}
impl PivotReversal {
/// Construct a Pivot Reversal with `left` bars before and `right` bars after
/// the pivot.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `left` or `right` is `0`.
pub fn new(left: usize, right: usize) -> Result<Self> {
if left == 0 || right == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
left,
right,
window: VecDeque::with_capacity(left + right + 1),
pivot_high: None,
pivot_low: None,
prev_close: None,
last: None,
})
}
/// Configured `(left, right)` strengths.
pub const fn params(&self) -> (usize, usize) {
(self.left, self.right)
}
/// Most recent confirmed pivot-high level, if any.
pub const fn pivot_high(&self) -> Option<f64> {
self.pivot_high
}
/// Most recent confirmed pivot-low level, if any.
pub const fn pivot_low(&self) -> Option<f64> {
self.pivot_low
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for PivotReversal {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let close = candle.close;
if self.window.len() == self.left + self.right + 1 {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < self.left + self.right + 1 {
self.prev_close = Some(close);
return None;
}
// Confirm the pivot candidate sitting `right` bars back.
let cand = self.window[self.left];
let is_high = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.left || c.high < cand.high);
let is_low = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.left || c.low > cand.low);
if is_high {
self.pivot_high = Some(cand.high);
}
if is_low {
self.pivot_low = Some(cand.low);
}
// Breakout crossing of the latest confirmed pivots by the current close.
let mut signal = 0.0;
if let (Some(ph), Some(prev)) = (self.pivot_high, self.prev_close) {
if close > ph && prev <= ph {
signal = 1.0;
}
}
if let (Some(pl), Some(prev)) = (self.pivot_low, self.prev_close) {
if close < pl && prev >= pl {
signal = -1.0;
}
}
self.prev_close = Some(close);
self.last = Some(signal);
Some(signal)
}
fn reset(&mut self) {
self.window.clear();
self.pivot_high = None;
self.pivot_low = None;
self.prev_close = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.left + self.right + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"PivotReversal"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_params() {
assert!(matches!(PivotReversal::new(0, 2), Err(Error::PeriodZero)));
assert!(matches!(PivotReversal::new(2, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = PivotReversal::new(2, 2).unwrap();
assert_eq!(p.params(), (2, 2));
assert_eq!(p.warmup_period(), 5);
assert_eq!(p.name(), "PivotReversal");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
assert_eq!(p.pivot_high(), None);
assert_eq!(p.pivot_low(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = PivotReversal::new(1, 1).unwrap();
let out = p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_some());
}
#[test]
fn confirms_pivot_high() {
// bar1 is a local high; once bar2 arrives it is confirmed.
let mut p = PivotReversal::new(1, 1).unwrap();
p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
assert_eq!(p.pivot_high(), Some(12.0));
}
#[test]
fn confirms_pivot_low() {
let mut p = PivotReversal::new(1, 1).unwrap();
p.batch(&[c(12.0, 11.0, 11.5), c(10.0, 8.0, 8.5), c(12.0, 11.0, 11.5)]);
assert_eq!(p.pivot_low(), Some(8.0));
}
#[test]
fn breakout_above_pivot_high_signals_plus_one() {
let mut p = PivotReversal::new(1, 1).unwrap();
// Form a pivot high at 12, then a close above 12 crosses it.
let candles = [
c(10.0, 9.0, 9.5), // index 0
c(12.0, 11.0, 11.5), // pivot-high candidate
c(10.0, 9.0, 9.5), // confirms pivot high = 12
c(11.0, 9.0, 9.0), // close 9.0 (below 12)
c(14.0, 12.5, 13.0), // close 13.0 > 12 and prev 9.0 <= 12 -> +1
];
let out = p.batch(&candles);
assert_eq!(out.last().unwrap(), &Some(1.0));
}
#[test]
fn breakdown_below_pivot_low_signals_minus_one() {
let mut p = PivotReversal::new(1, 1).unwrap();
let candles = [
c(12.0, 11.0, 11.5),
c(10.0, 8.0, 8.5), // pivot-low candidate
c(12.0, 11.0, 11.5), // confirms pivot low = 8
c(12.0, 9.0, 11.0), // close 11 (above 8)
c(9.0, 6.0, 7.0), // close 7 < 8 and prev 11 >= 8 -> -1
];
let out = p.batch(&candles);
assert_eq!(out.last().unwrap(), &Some(-1.0));
}
#[test]
fn no_break_is_zero() {
let mut p = PivotReversal::new(1, 1).unwrap();
let candles = [
c(10.0, 9.0, 9.5),
c(12.0, 11.0, 11.5),
c(10.0, 9.0, 9.5),
c(10.5, 9.0, 9.8),
];
let out = p.batch(&candles);
assert_eq!(out.last().unwrap(), &Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut p = PivotReversal::new(1, 1).unwrap();
p.batch(&[c(10.0, 9.0, 9.5), c(12.0, 11.0, 11.5), c(10.0, 9.0, 9.5)]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
assert_eq!(p.pivot_high(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
c(base + 1.0, base - 1.0, base)
})
.collect();
let batch = PivotReversal::new(2, 2).unwrap().batch(&candles);
let mut b = PivotReversal::new(2, 2).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).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());
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Reflex** — a near-zero-lag oscillator that measures how far the
/// smoothed price has deviated from the straight line connecting its endpoints
/// over the lookback.
///
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
/// Feb 2020):
///
/// ```text
/// Filt = SuperSmoother(price, period)
/// slope = (Filt[period] Filt[0]) / period (line over the window)
/// sum = mean over i=1..period of ( Filt[0] + i·slope Filt[i] )
/// ms = 0.04·sum² + 0.96·ms[1] (adaptive normaliser)
/// Reflex = sum / sqrt(ms) (0 if ms == 0)
/// ```
///
/// Reflex fits a straight line across the SuperSmoothed price over `period` bars
/// and averages the deviation of the curve from that line. Because the line uses
/// both endpoints, the measure has almost no lag — it crosses zero essentially at
/// the cycle turns. The adaptive mean-square normaliser rescales the output to a
/// roughly `±3` range regardless of price, so the same thresholds work on any
/// instrument. Its sibling [`Trendflex`](crate::Trendflex) uses the deviation from
/// the *current* value instead of the line, making it trend- rather than
/// cycle-sensitive.
///
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
/// is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Reflex};
///
/// let mut indicator = Reflex::new(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 Reflex {
period: usize,
smoother: SuperSmoother,
filt: VecDeque<f64>,
ms: f64,
last: Option<f64>,
}
impl Reflex {
/// Construct a Reflex with the given lookback `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoother: SuperSmoother::new(period)?,
filt: VecDeque::with_capacity(period + 1),
ms: 0.0,
last: None,
})
}
/// Configured lookback 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 Reflex {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.smoother.update(price)?;
if self.filt.len() == self.period + 1 {
self.filt.pop_front();
}
self.filt.push_back(filt);
if self.filt.len() < self.period + 1 {
return None;
}
// Newest at index `period`, oldest (period bars ago) at index 0.
let newest = self.filt[self.period];
let oldest = self.filt[0];
let slope = (oldest - newest) / self.period as f64;
let mut sum = 0.0;
for i in 1..=self.period {
sum += (newest + i as f64 * slope) - self.filt[self.period - i];
}
sum /= self.period as f64;
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
let reflex = if self.ms > 0.0 {
sum / self.ms.sqrt()
} else {
0.0
};
self.last = Some(reflex);
Some(reflex)
}
fn reset(&mut self) {
self.smoother.reset();
self.filt.clear();
self.ms = 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 {
"Reflex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Reflex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let r = Reflex::new(20).unwrap();
assert_eq!(r.period(), 20);
assert_eq!(r.warmup_period(), 21);
assert_eq!(r.name(), "Reflex");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = Reflex::new(5).unwrap();
let xs: Vec<f64> = (0..12)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
.collect();
let out = r.batch(&xs);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_input_is_zero() {
// A flat price is exactly its own straight line -> zero deviation -> 0.
let mut r = Reflex::new(10).unwrap();
for v in r.batch(&[50.0; 100]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn cyclic_input_oscillates_around_zero() {
let mut r = Reflex::new(20).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = r.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut r = Reflex::new(10).unwrap();
r.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = r.value();
assert_eq!(r.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut r = Reflex::new(10).unwrap();
r.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Reflex::new(20).unwrap().batch(&xs);
let mut b = Reflex::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,261 @@
//! Rolling Min-Max Scaler — normalises the latest value to `[0, 1]` over a window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Min-Max Scaler — maps the current value onto `[0, 1]` relative to the
/// minimum and maximum of the trailing window.
///
/// ```text
/// scaled = (x min(window)) / (max(window) min(window))
/// ```
///
/// This is the streaming form of scikit-learn's `MinMaxScaler` applied over a
/// sliding window: `0` means the value is the lowest in the window, `1` the
/// highest, `0.5` the midpoint of the range. It is the engine behind oscillators
/// like the Stochastic %K and a handy normaliser for feeding any indicator into a
/// bounded model input. Because it rescales to the window's own range it is
/// scale-free across instruments.
///
/// The output is in `[0, 1]`. A flat window (`max == min`) has no range to scale
/// against and returns the neutral `0.5`. The first value lands after `period`
/// inputs; each `update` scans the window in O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingMinMaxScaler};
///
/// let mut indicator = RollingMinMaxScaler::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RollingMinMaxScaler {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl RollingMinMaxScaler {
/// Construct a rolling min-max scaler over `period` values.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidPeriod`] if `period < 2` (a range needs two points).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "min-max scaler needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for RollingMinMaxScaler {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in &self.window {
min = min.min(v);
max = max.max(v);
}
let range = max - min;
let scaled = if range > 0.0 {
(input - min) / range
} else {
0.5
};
self.last = Some(scaled);
Some(scaled)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"RollingMinMaxScaler"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_period() {
assert!(matches!(
RollingMinMaxScaler::new(0),
Err(Error::PeriodZero)
));
assert!(matches!(
RollingMinMaxScaler::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(RollingMinMaxScaler::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let s = RollingMinMaxScaler::new(14).unwrap();
assert_eq!(s.period(), 14);
assert_eq!(s.warmup_period(), 14);
assert_eq!(s.name(), "RollingMinMaxScaler");
assert!(!s.is_ready());
assert_eq!(s.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut s = RollingMinMaxScaler::new(4).unwrap();
let out = s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn highest_in_window_is_one() {
let mut s = RollingMinMaxScaler::new(4).unwrap();
// last value is the highest -> 1.0.
let last = s
.batch(&[1.0, 2.0, 3.0, 4.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
}
#[test]
fn lowest_in_window_is_zero() {
let mut s = RollingMinMaxScaler::new(4).unwrap();
let last = s
.batch(&[4.0, 3.0, 2.0, 1.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn midpoint_is_half() {
let mut s = RollingMinMaxScaler::new(3).unwrap();
// window [0, 2, 1]: min 0, max 2, current 1 -> 0.5.
let last = s
.batch(&[0.0, 2.0, 1.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
}
#[test]
fn flat_window_is_half() {
let mut s = RollingMinMaxScaler::new(4).unwrap();
let last = s.batch(&[7.0; 8]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut s = RollingMinMaxScaler::new(14).unwrap();
for v in s
.batch(
&(0..200)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((0.0..=1.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut s = RollingMinMaxScaler::new(4).unwrap();
let ready = s
.batch(&[1.0, 2.0, 3.0, 4.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(s.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut s = RollingMinMaxScaler::new(4).unwrap();
s.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.value(), None);
assert_eq!(s.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = RollingMinMaxScaler::new(14).unwrap().batch(&xs);
let mut b = RollingMinMaxScaler::new(14).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+180 -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,
})
}
@@ -66,17 +81,87 @@ impl Rsi {
self.last_value
}
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
///
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
/// default. RSI is a recursive (IIR) filter — Wilder smoothing — so it cannot
/// be SIMD-vectorized any more than the C peers manage; the win is purely in
/// stripping per-tick overhead. For a fresh indicator over an all-finite slice
/// long enough to seed (`n > period`) it runs the seed once and then the bare
/// smoothing recurrence in a tight loop with no per-tick `is_finite`/`has_prev`/
/// `avgs_seeded` branch and no `Option`, using the identical division at the
/// seed and `mul_add`/`rsi_from_avgs` afterwards — so it is *bit-for-bit* equal
/// to replaying `update`. Shorter or non-fresh/non-finite inputs defer to the
/// exact `update` replay.
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
let p = self.period;
let n = inputs.len();
if self.has_prev
|| self.avgs_seeded
|| !self.seed_buf_gains.is_empty()
|| n <= p
|| !inputs.iter().all(|x| x.is_finite())
{
return inputs
.iter()
.map(|&x| self.update(x).unwrap_or(f64::NAN))
.collect();
}
// Warmup `[0, p)` is `NaN`; outputs from index `p` on are pushed once each.
let mut out = vec![f64::NAN; p];
out.reserve(n - p);
// Seed from the first `period` diffs (inputs[1..=p]); index 0 only sets the
// baseline. Retain the seed gains/losses exactly as `update` leaves them.
let mut prev = inputs[0];
let (mut sum_gain, mut sum_loss) = (0.0_f64, 0.0_f64);
for &x in &inputs[1..=p] {
let diff = x - prev;
prev = x;
let gain = if diff > 0.0 { diff } else { 0.0 };
let loss = if diff < 0.0 { -diff } else { 0.0 };
self.seed_buf_gains.push(gain);
self.seed_buf_losses.push(loss);
sum_gain += gain;
sum_loss += loss;
}
let p_f64 = p as f64;
let mut ag = sum_gain / p_f64;
let mut al = sum_loss / p_f64;
out.push(Self::rsi_from_avgs(ag, al));
// Steady state: Wilder smoothing, reciprocal hoisted, one `rsi_from_avgs`.
for &x in &inputs[p + 1..] {
let diff = x - prev;
prev = x;
let gain = if diff > 0.0 { diff } else { 0.0 };
let loss = if diff < 0.0 { -diff } else { 0.0 };
ag = ag.mul_add(self.n_minus_1, gain) * self.inv_period;
al = al.mul_add(self.n_minus_1, loss) * self.inv_period;
out.push(Self::rsi_from_avgs(ag, al));
}
// Leave state where a full `update` replay would.
self.prev_close = prev;
self.has_prev = true;
self.avg_gain = ag;
self.avg_loss = al;
self.avgs_seeded = true;
self.last_value = Some(out[n - 1]);
out
}
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 +175,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 +204,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 +215,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;
}
@@ -355,6 +446,65 @@ mod tests {
assert_eq!(rsi.value(), before);
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
fn rsi_replay(period: usize, series: &[f64]) -> Vec<f64> {
let mut r = Rsi::new(period).unwrap();
series
.iter()
.map(|&x| r.update(x).unwrap_or(f64::NAN))
.collect()
}
#[test]
fn batch_nan_fast_path_is_bit_identical() {
let series: Vec<f64> = (0..300)
.map(|i| (f64::from(i) * 0.3).sin() * 5.0 + f64::from(i) * 0.1 + 100.0)
.collect();
let mut rsi = Rsi::new(14).unwrap();
let got = rsi.batch_nan(&series);
assert!(bits_eq(&got, &rsi_replay(14, &series)));
let mut ref_rsi = Rsi::new(14).unwrap();
for &x in &series {
ref_rsi.update(x);
}
assert_eq!(rsi.update(123.0), ref_rsi.update(123.0));
}
#[test]
fn batch_nan_falls_back_on_non_finite() {
let series = [10.0, 11.0, 9.0, f64::NAN, 12.0, 13.0, 8.0];
let mut rsi = Rsi::new(3).unwrap();
assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series)));
}
#[test]
fn batch_nan_falls_back_when_not_fresh() {
let mut rsi = Rsi::new(3).unwrap();
rsi.update(50.0);
let series = [51.0, 49.0, 52.0, 53.0, 50.0];
let mut ref_rsi = Rsi::new(3).unwrap();
ref_rsi.update(50.0);
let want: Vec<f64> = series
.iter()
.map(|&x| ref_rsi.update(x).unwrap_or(f64::NAN))
.collect();
assert!(bits_eq(&rsi.batch_nan(&series), &want));
}
#[test]
fn batch_nan_too_short_to_seed_falls_back() {
// n <= period: routed to the exact replay (cannot seed yet).
let series = [10.0, 11.0, 12.0];
let mut rsi = Rsi::new(3).unwrap();
assert!(bits_eq(&rsi.batch_nan(&series), &rsi_replay(3, &series)));
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
@@ -0,0 +1,337 @@
//! Sample Entropy (`SampEn`) — the regularity / predictability of a window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Population standard deviation of a slice (used for the matching tolerance).
fn population_stddev(window: &[f64]) -> f64 {
let n = window.len() as f64;
let mean = window.iter().sum::<f64>() / n;
let var = window.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
var.max(0.0).sqrt()
}
/// Whether two length-`len` templates starting at `i` and `j` match within the
/// Chebyshev tolerance `tol`.
fn templates_match(window: &[f64], i: usize, j: usize, len: usize, tol: f64) -> bool {
for k in 0..len {
if (window[i + k] - window[j + k]).abs() > tol {
return false;
}
}
true
}
/// Sample Entropy (`SampEn`) — Richman & Moorman's measure of how *regular* (i.e.
/// predictable) a series is: the negative log conditional probability that two
/// sub-sequences similar for `m` points stay similar at the next point.
///
/// ```text
/// tol = r_factor · stddev(window)
/// B = # template pairs of length m within tol (i < j)
/// A = # template pairs of length m+1 within tol (i < j)
/// `SampEn` = ln(A / B)
/// ```
///
/// Low `SampEn` means the window is **regular** — patterns of length `m` reliably
/// extend to length `m + 1`, the fingerprint of a trending or cyclic market. High
/// `SampEn` means the series is **irregular** — knowing the last `m` points tells
/// you little about the next, the fingerprint of noise. Unlike the older
/// approximate entropy (`ApEn`), `SampEn` excludes self-matches, so it is far less
/// biased on short windows.
///
/// The tolerance is `r_factor` times the window's standard deviation, so the
/// measure self-scales. A perfectly flat window (`stddev == 0`) is maximally
/// regular and returns `0`. If no length-`m` pairs match, the entropy is
/// undefined and `0` is returned; if length-`m` pairs match but none extend, the
/// estimator falls back to treating the unseen count as one (`ln(1/B) = ln(B)`).
/// The first value lands after `period` inputs; each `update` is O(`period²`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SampleEntropy};
///
/// let mut indicator = SampleEntropy::new(50, 2, 0.2).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update((f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SampleEntropy {
period: usize,
emb_dim: usize,
r_factor: f64,
window: VecDeque<f64>,
last: Option<f64>,
}
impl SampleEntropy {
/// Construct a Sample Entropy over `period` values with embedding dimension
/// `m` and tolerance factor `r_factor`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period` or `m` is `0`,
/// [`Error::InvalidPeriod`] if `period < m + 2` (no length-`m+1` template
/// pairs otherwise), and [`Error::InvalidParameter`] if `r_factor` is not
/// finite and positive.
pub fn new(period: usize, m: usize, r_factor: f64) -> Result<Self> {
if period == 0 || m == 0 {
return Err(Error::PeriodZero);
}
if period < m + 2 {
return Err(Error::InvalidPeriod {
message: "sample entropy needs period >= m + 2",
});
}
if !r_factor.is_finite() || r_factor <= 0.0 {
return Err(Error::InvalidParameter {
message: "sample entropy r_factor must be finite and positive",
});
}
Ok(Self {
period,
emb_dim: m,
r_factor,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured `(period, m, r_factor)`.
pub const fn params(&self) -> (usize, usize, f64) {
(self.period, self.emb_dim, self.r_factor)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn compute(&self) -> f64 {
let window: Vec<f64> = self.window.iter().copied().collect();
let std = population_stddev(&window);
if std == 0.0 {
return 0.0;
}
let tol = self.r_factor * std;
let m = self.emb_dim;
// Restrict both template lengths to the same index range so A and B share
// their candidate pairs: there are `period m` length-(m+1) templates.
let count = self.period - m;
let mut matches_m = 0u64;
let mut matches_m1 = 0u64;
for i in 0..count {
for j in (i + 1)..count {
if templates_match(&window, i, j, m, tol) {
matches_m += 1;
if templates_match(&window, i, j, m + 1, tol) {
matches_m1 += 1;
}
}
}
}
if matches_m == 0 {
return 0.0;
}
if matches_m1 == 0 {
// No length-(m+1) matches: fall back to one unseen count.
return (matches_m as f64).ln();
}
-((matches_m1 as f64) / (matches_m as f64)).ln()
}
}
impl Indicator for SampleEntropy {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SampleEntropy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
SampleEntropy::new(0, 2, 0.2),
Err(Error::PeriodZero)
));
assert!(matches!(
SampleEntropy::new(50, 0, 0.2),
Err(Error::PeriodZero)
));
assert!(matches!(
SampleEntropy::new(3, 2, 0.2),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
SampleEntropy::new(50, 2, 0.0),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let s = SampleEntropy::new(50, 2, 0.2).unwrap();
assert_eq!(s.params(), (50, 2, 0.2));
assert_eq!(s.warmup_period(), 50);
assert_eq!(s.name(), "SampleEntropy");
assert!(!s.is_ready());
assert_eq!(s.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
let xs: Vec<f64> = (0..14).map(|i| (f64::from(i) * 0.5).sin()).collect();
let out = s.batch(&xs);
for v in out.iter().take(9) {
assert!(v.is_none());
}
assert!(out[9].is_some());
}
#[test]
fn constant_window_is_zero() {
let mut s = SampleEntropy::new(20, 2, 0.2).unwrap();
let last = s.batch(&[5.0; 30]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_is_non_negative() {
let mut s = SampleEntropy::new(40, 2, 0.2).unwrap();
for v in s
.batch(
&(0..200)
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!(v >= 0.0, "sample entropy must be non-negative, got {v}");
}
}
#[test]
fn regular_below_irregular() {
// A smooth sine is far more regular (lower `SampEn`) than a chaotic
// logistic-map series. (An *alternating* series would be periodic, hence
// regular too -- chaos is what makes the window genuinely unpredictable.)
let smooth: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.2).sin() * 5.0).collect();
let mut x = 0.37_f64;
let chaotic: Vec<f64> = (0..60)
.map(|_| {
x = 3.99 * x * (1.0 - x);
x * 5.0
})
.collect();
let s_smooth = SampleEntropy::new(50, 2, 0.2)
.unwrap()
.batch(&smooth)
.into_iter()
.flatten()
.last()
.unwrap();
let s_chaotic = SampleEntropy::new(50, 2, 0.2)
.unwrap()
.batch(&chaotic)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(
s_smooth <= s_chaotic,
"smooth ({s_smooth}) should be <= chaotic ({s_chaotic})"
);
}
#[test]
fn ignores_non_finite() {
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
let ready = s.batch(&xs).into_iter().flatten().last().unwrap();
assert_eq!(s.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
s.batch(&xs);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.value(), None);
assert_eq!(s.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = SampleEntropy::new(40, 2, 0.2).unwrap().batch(&xs);
let mut b = SampleEntropy::new(40, 2, 0.2).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn falls_back_when_no_m_plus_one_matches() {
// `[1, 1, 1, 5]` with m = 2: the length-2 template `(1, 1)` repeats
// (matches_m > 0) but no length-3 template repeats (matches_m1 == 0),
// so SampEn takes the `ln(matches_m)` fallback branch.
let xs = [1.0, 1.0, 1.0, 5.0];
let v = SampleEntropy::new(4, 2, 0.2)
.unwrap()
.batch(&xs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(v.is_finite() && v >= 0.0, "got {v}");
}
}
@@ -0,0 +1,261 @@
//! Shannon Entropy — the information content of a price window's distribution.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Shannon Entropy — the Shannon information entropy (in **bits**) of the
/// distribution of values in a rolling window, after binning them into a fixed
/// number of equal-width buckets.
///
/// ```text
/// bucket each of the last `period` values into `bins` equal-width bins over
/// [min, max] of the window
/// p_i = count_i / period
/// H = Σ p_i · log2(p_i) (over non-empty bins)
/// ```
///
/// Entropy measures how *spread out* and unpredictable the recent values are. A
/// window concentrated in one bin (a flat or tightly-ranging market) has low
/// entropy near `0`; a window whose values are spread evenly across all bins (a
/// noisy, directionless market) approaches the maximum `log2(bins)`. Traders use
/// it as a **regime filter**: low entropy favours trend/breakout strategies, high
/// entropy favours mean-reversion or standing aside.
///
/// The output lies in `[0, log2(bins)]`. A degenerate window where every value is
/// identical (`max == min`) returns `0`. The first value lands after `period`
/// inputs; each `update` rebins the window in O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, ShannonEntropy};
///
/// let mut indicator = ShannonEntropy::new(32, 8).unwrap();
/// let mut last = None;
/// for i in 0..64 {
/// last = indicator.update((f64::from(i) * 0.7).sin() * 10.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ShannonEntropy {
period: usize,
bins: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl ShannonEntropy {
/// Construct a Shannon entropy over `period` values binned into `bins`
/// buckets.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either argument is `0`, or
/// [`Error::InvalidPeriod`] if `bins < 2` (entropy needs at least two bins).
pub fn new(period: usize, bins: usize) -> Result<Self> {
if period == 0 || bins == 0 {
return Err(Error::PeriodZero);
}
if bins < 2 {
return Err(Error::InvalidPeriod {
message: "Shannon entropy needs bins >= 2",
});
}
Ok(Self {
period,
bins,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured `(period, bins)`.
pub const fn params(&self) -> (usize, usize) {
(self.period, self.bins)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for ShannonEntropy {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in &self.window {
min = min.min(v);
max = max.max(v);
}
if max <= min {
// Degenerate window: all values identical -> zero entropy.
self.last = Some(0.0);
return Some(0.0);
}
let width = (max - min) / self.bins as f64;
let mut counts = vec![0usize; self.bins];
for &v in &self.window {
// `(v - min) / width` is in [0, bins]; the cast truncates toward zero
// (intended) and the value is non-negative, then clamped to the last
// bin so the index is always valid.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let raw = ((v - min) / width) as usize;
let idx = raw.min(self.bins - 1);
counts[idx] += 1;
}
let n = self.period as f64;
let mut h = 0.0;
for &count in &counts {
if count > 0 {
let p = count as f64 / n;
h -= p * p.log2();
}
}
self.last = Some(h);
Some(h)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ShannonEntropy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(ShannonEntropy::new(0, 8), Err(Error::PeriodZero)));
assert!(matches!(ShannonEntropy::new(32, 0), Err(Error::PeriodZero)));
assert!(matches!(
ShannonEntropy::new(32, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let e = ShannonEntropy::new(32, 8).unwrap();
assert_eq!(e.params(), (32, 8));
assert_eq!(e.warmup_period(), 32);
assert_eq!(e.name(), "ShannonEntropy");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = ShannonEntropy::new(4, 4).unwrap();
let out = e.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn constant_window_is_zero() {
let mut e = ShannonEntropy::new(8, 4).unwrap();
let last = e.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn uniform_window_is_max_entropy() {
// One value per bin -> uniform distribution -> H = log2(bins).
let mut e = ShannonEntropy::new(4, 4).unwrap();
// Values 0,1,2,3 with min=0,max=3,width=0.75 -> bins 0,1,2,3.
let last = e
.batch(&[0.0, 1.0, 2.0, 3.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-9); // log2(4) = 2
}
#[test]
fn output_in_range() {
let mut e = ShannonEntropy::new(32, 8).unwrap();
let max_h = 8f64.log2();
for v in e
.batch(
&(0..200)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((0.0..=max_h + 1e-9).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut e = ShannonEntropy::new(4, 4).unwrap();
let ready = e
.batch(&[1.0, 2.0, 3.0, 4.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(e.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut e = ShannonEntropy::new(4, 4).unwrap();
e.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
assert_eq!(e.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = ShannonEntropy::new(32, 8).unwrap().batch(&xs);
let mut b = ShannonEntropy::new(32, 8).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+158 -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,12 +80,68 @@ 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
}
}
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
///
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
/// default via inherent-method resolution. For a fresh, all-finite slice it
/// inlines `update`'s rolling sum and drift-reseed, writing the mean as a bare
/// `f64` (warmup → `NaN`) instead of allocating an `Option<f64>` per element
/// and walking the result a second time. Same add/subtract order, same reseed
/// cadence, same `sum / period` division — so it is *bit-for-bit* equal to
/// replaying `update`, including the long-stream drift bound. Any other state,
/// or a non-finite element, defers to the exact `update` replay.
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
let p = self.period;
if self.count != 0
|| self.updates_since_recompute != 0
|| !inputs.iter().all(|x| x.is_finite())
{
return inputs
.iter()
.map(|&x| self.update(x).unwrap_or(f64::NAN))
.collect();
}
let p_f64 = p as f64;
let mut out = Vec::with_capacity(inputs.len());
for &x in inputs {
if self.count == p {
self.sum -= self.buf[self.head];
self.buf[self.head] = x;
self.sum += x;
} else {
self.buf[self.head] = x;
self.sum += x;
self.count += 1;
}
self.head += 1;
if self.head == p {
self.head = 0;
}
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
self.sum = self.buf[self.head..]
.iter()
.chain(&self.buf[..self.head])
.copied()
.sum();
self.updates_since_recompute = 0;
}
out.push(if self.count == p {
self.sum / p_f64
} else {
f64::NAN
});
}
out
}
}
impl Indicator for Sma {
@@ -89,25 +152,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 +195,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 +208,7 @@ mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
use std::collections::VecDeque;
#[test]
fn new_rejects_zero_period() {
@@ -223,6 +302,69 @@ mod tests {
}
}
/// NaN-aware bit-equality for the `f64`-with-NaN-warmup batch outputs.
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
fn sma_replay(period: usize, series: &[f64]) -> Vec<f64> {
let mut s = Sma::new(period).unwrap();
series
.iter()
.map(|&x| s.update(x).unwrap_or(f64::NAN))
.collect()
}
#[test]
fn batch_nan_fast_path_is_bit_identical_with_reseed() {
// > 16*period inputs so the drift-reseed branch fires inside batch_nan.
let series: Vec<f64> = (0..500)
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
.collect();
let mut sma = Sma::new(14).unwrap();
let got = sma.batch_nan(&series);
assert!(bits_eq(&got, &sma_replay(14, &series)));
// State left where the replay would: continued updates agree.
let mut ref_sma = Sma::new(14).unwrap();
for &x in &series {
ref_sma.update(x);
}
assert_eq!(sma.update(42.0), ref_sma.update(42.0));
}
#[test]
fn batch_nan_falls_back_on_non_finite() {
let series = [1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0];
let mut sma = Sma::new(3).unwrap();
assert!(bits_eq(&sma.batch_nan(&series), &sma_replay(3, &series)));
}
#[test]
fn batch_nan_falls_back_when_not_fresh() {
let mut sma = Sma::new(3).unwrap();
sma.update(99.0);
let series = [1.0, 2.0, 3.0, 4.0];
let mut ref_sma = Sma::new(3).unwrap();
ref_sma.update(99.0);
let want: Vec<f64> = series
.iter()
.map(|&x| ref_sma.update(x).unwrap_or(f64::NAN))
.collect();
assert!(bits_eq(&sma.batch_nan(&series), &want));
}
#[test]
fn batch_nan_sub_period_slice_is_all_nan() {
let series = [1.0, 2.0, 3.0];
let mut sma = Sma::new(10).unwrap();
let got = sma.batch_nan(&series);
assert!(bits_eq(&got, &sma_replay(10, &series)));
assert!(got.iter().all(|x| x.is_nan()));
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
@@ -0,0 +1,156 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Camouflage — a hidden-strength/weakness 1-bar reversal pattern.
//!
//! TD Camouflage spots a bar that *looks* weak (or strong) on its close-to-close
//! comparison but reveals the opposite intrabar, "camouflaging" a reversal.
//!
//! - **Buy signal** (`+1.0`): `close < close[-1]` (a lower close, looks bearish),
//! yet `close > open` (it actually closed up on the bar) and `low < low[-1]`
//! (it dipped to a new low and was bought back) — hidden accumulation.
//! - **Sell signal** (`-1.0`): `close > close[-1]`, `close < open`, and
//! `high > high[-1]` — hidden distribution.
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Camouflage — 1-bar hidden-strength/weakness reversal detector.
#[derive(Debug, Clone, Default)]
pub struct TdCamouflage {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdCamouflage {
/// Construct a new `TdCamouflage`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdCamouflage {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let v = if candle.close < prev.close && candle.close > candle.open && candle.low < prev.low
{
1.0
} else if candle.close > prev.close && candle.close < candle.open && candle.high > prev.high
{
-1.0
} else {
0.0
};
self.prev = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDCamouflage"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdCamouflage::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDCamouflage");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdCamouflage::new();
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
assert!(td.update(c(10.0, 11.0, 8.0, 9.5)).is_some());
}
#[test]
fn bullish_camouflage_buy() {
// prev close 10. Current: close 9.5 < 10 (lower close), close 9.5 > open 9.0,
// low 7.0 < prev low 8.0 -> buy.
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 8.0, 10.0));
assert_eq!(td.update(c(9.0, 10.0, 7.0, 9.5)), Some(1.0));
}
#[test]
fn bearish_camouflage_sell() {
// prev close 10. Current: close 10.5 > 10, close 10.5 < open 11.0,
// high 12.0 > prev high 11.0 -> sell.
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 8.0, 10.0));
assert_eq!(td.update(c(11.0, 12.0, 10.0, 10.5)), Some(-1.0));
}
#[test]
fn no_pattern_is_zero() {
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 9.0, 10.0));
assert_eq!(td.update(c(10.0, 11.5, 9.5, 11.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdCamouflage::new();
td.update(c(10.0, 11.0, 9.0, 10.0));
td.update(c(9.0, 10.0, 7.0, 9.5));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 1.0, b - 1.0, b + 0.2)
})
.collect();
let batch = TdCamouflage::new().batch(&candles);
let mut b = TdCamouflage::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,159 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Clop — a 2-bar open/close engulfing reversal.
//!
//! TD Clop ("CLose/OPen") fires when the current bar's open opens beyond **both**
//! the prior bar's open and close, and its close finishes back beyond both — an
//! open-gap that fully reverses, signalling a turn.
//!
//! - **Buy signal** (`+1.0`): `open < open[-1]` AND `open < close[-1]`
//! (opens below the whole prior body) AND `close > open[-1]` AND
//! `close > close[-1]` (closes above it).
//! - **Sell signal** (`-1.0`): `open > open[-1]` AND `open > close[-1]` AND
//! `close < open[-1]` AND `close < close[-1]`.
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Clop — 2-bar open/close engulfing reversal detector.
#[derive(Debug, Clone, Default)]
pub struct TdClop {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdClop {
/// Construct a new `TdClop`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdClop {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let below_body = candle.open < prev.open && candle.open < prev.close;
let above_body = candle.close > prev.open && candle.close > prev.close;
let over_body = candle.open > prev.open && candle.open > prev.close;
let under_body = candle.close < prev.open && candle.close < prev.close;
let v = if below_body && above_body {
1.0
} else if over_body && under_body {
-1.0
} else {
0.0
};
self.prev = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDClop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, close: f64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdClop::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDClop");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdClop::new();
assert_eq!(td.update(c(10.0, 11.0)), Some(0.0));
assert!(td.update(c(9.0, 12.0)).is_some());
}
#[test]
fn bullish_clop_buy() {
// prev body [10, 11]. Current open 9 < both, close 12 > both -> buy.
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
assert_eq!(td.update(c(9.0, 12.0)), Some(1.0));
}
#[test]
fn bearish_clop_sell() {
// prev body [10, 11]. Current open 12 > both, close 9 < both -> sell.
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
assert_eq!(td.update(c(12.0, 9.0)), Some(-1.0));
}
#[test]
fn no_pattern_is_zero() {
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
assert_eq!(td.update(c(10.5, 11.5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdClop::new();
td.update(c(10.0, 11.0));
td.update(c(9.0, 12.0));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(10.0, 11.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 0.5)
})
.collect();
let batch = TdClop::new().batch(&candles);
let mut b = TdClop::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,162 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Clopwin — a 2-bar "close/open within" inside-body pattern.
//!
//! TD Clopwin ("CLose/OPen WInthIN") is the inside-body cousin of TD Clop: the
//! current bar's open **and** close both sit within the prior bar's real body,
//! marking a compression bar whose direction hints at the next move.
//!
//! - **Buy signal** (`+1.0`): current `open` and `close` are both inside the prior
//! bar's body `[min(open,close)[-1], max(open,close)[-1]]` AND `close >= open`
//! (a bullish inside bar).
//! - **Sell signal** (`-1.0`): both inside the prior body AND `close < open`
//! (a bearish inside bar).
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Clopwin — 2-bar inside-body compression pattern detector.
#[derive(Debug, Clone, Default)]
pub struct TdClopwin {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdClopwin {
/// Construct a new `TdClopwin`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdClopwin {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let body_low = prev.open.min(prev.close);
let body_high = prev.open.max(prev.close);
let open_in = candle.open >= body_low && candle.open <= body_high;
let close_in = candle.close >= body_low && candle.close <= body_high;
let v = if open_in && close_in {
if candle.close >= candle.open {
1.0
} else {
-1.0
}
} else {
0.0
};
self.prev = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDClopwin"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, close: f64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdClopwin::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDClopwin");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdClopwin::new();
assert_eq!(td.update(c(10.0, 14.0)), Some(0.0));
assert!(td.update(c(11.0, 13.0)).is_some());
}
#[test]
fn bullish_inside_body_buy() {
// prev body [10, 14]. Current open 11, close 13 both inside, close>open -> +1.
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
assert_eq!(td.update(c(11.0, 13.0)), Some(1.0));
}
#[test]
fn bearish_inside_body_sell() {
// prev body [10, 14]. Current open 13, close 11 inside, close<open -> -1.
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
assert_eq!(td.update(c(13.0, 11.0)), Some(-1.0));
}
#[test]
fn outside_body_is_zero() {
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
// close 16 outside the prior body -> 0.
assert_eq!(td.update(c(11.0, 16.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdClopwin::new();
td.update(c(10.0, 14.0));
td.update(c(11.0, 13.0));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(10.0, 14.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 0.3)
})
.collect();
let batch = TdClopwin::new().batch(&candles);
let mut b = TdClopwin::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,295 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD D-Wave — a simplified Elliott-style swing-wave counter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Tom DeMark **TD D-Wave** — a streaming wave counter that labels the market's
/// swing sequence with an Elliott-style `15` impulse / `AC` correction count.
///
/// TD D-Wave is DeMark's objective alternative to discretionary Elliott Wave
/// counting. This streaming implementation detects alternating swing pivots with a
/// symmetric fractal of half-width `strength`, and advances a counter through the
/// eight-leg cycle each time a new swing leg is confirmed:
///
/// ```text
/// legs: 1 → 2 → 3 → 4 → 5 → A(6) → B(7) → C(8) → 1 …
/// output = current wave number, 1.0..8.0 (6/7/8 = corrective A/B/C)
/// ```
///
/// The number tells you which wave of the cycle price is currently working on — a
/// running map of impulse versus correction that updates as each swing confirms.
/// This is a **simplified** swing-leg count (it does not enforce Elliott's price
/// ratio and overlap rules); treat it as a structural guide, not a strict wave
/// label.
///
/// Readiness is data-dependent: the first value appears once the first swing pivot
/// confirms (`strength` bars after it forms). `warmup_period` returns the minimum
/// bars to confirm one pivot. Each `update` is O(`strength`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TdDWave};
///
/// let mut indicator = TdDWave::new(2).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// let _ = last;
/// ```
#[derive(Debug, Clone)]
pub struct TdDWave {
strength: usize,
window: VecDeque<Candle>,
last_is_high: Option<bool>,
last_extreme: f64,
wave: usize,
last_value: Option<f64>,
}
impl TdDWave {
/// Construct a TD D-Wave with the given fractal `strength`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `strength == 0`.
pub fn new(strength: usize) -> Result<Self> {
if strength == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
strength,
window: VecDeque::with_capacity(2 * strength + 1),
last_is_high: None,
last_extreme: 0.0,
wave: 0,
last_value: None,
})
}
/// Configured fractal strength.
pub const fn strength(&self) -> usize {
self.strength
}
/// Current wave number if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn advance(&mut self, is_high: bool, price: f64) {
match self.last_is_high {
Some(prev) if prev == is_high => {
// Same-direction extreme: extend the current leg if more extreme.
let extends = if is_high {
price > self.last_extreme
} else {
price < self.last_extreme
};
if extends {
self.last_extreme = price;
}
}
_ => {
// A new alternating leg: advance the wave counter (1..8 cycle).
self.wave = self.wave % 8 + 1;
self.last_is_high = Some(is_high);
self.last_extreme = price;
self.last_value = Some(self.wave as f64);
}
}
}
}
impl Indicator for TdDWave {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let span = 2 * self.strength + 1;
if self.window.len() == span {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() == span {
let center = self.window[self.strength];
let is_high = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.high < center.high);
let is_low = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.low > center.low);
if is_high && !is_low {
self.advance(true, center.high);
} else if is_low && !is_high {
self.advance(false, center.low);
}
}
self.last_value
}
fn reset(&mut self) {
self.window.clear();
self.last_is_high = None;
self.last_extreme = 0.0;
self.wave = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
2 * self.strength + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDDWave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64) -> Candle {
Candle::new_unchecked(
f64::midpoint(high, low),
high,
low,
f64::midpoint(high, low),
1_000.0,
0,
)
}
fn zigzag() -> Vec<Candle> {
(0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
c(base + 1.0, base - 1.0)
})
.collect()
}
#[test]
fn rejects_zero_strength() {
assert!(matches!(TdDWave::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let td = TdDWave::new(2).unwrap();
assert_eq!(td.strength(), 2);
assert_eq!(td.warmup_period(), 5);
assert_eq!(td.name(), "TDDWave");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn counts_waves_on_swings() {
let mut td = TdDWave::new(2).unwrap();
let out = td.batch(&zigzag());
assert!(out.iter().any(Option::is_some));
assert!(td.is_ready());
}
#[test]
fn same_direction_pivots_extend_one_leg() {
// Strictly decreasing lows mean no bar is ever a low pivot, so the
// confirmed pivots are all highs. Consecutive same-direction highs
// exercise the `extends` branch (true at 30 > 20, false at 25 < 30)
// without ever advancing the wave past leg 1.
let mut td = TdDWave::new(1).unwrap();
let bars = [
(10.0, 100.0),
(20.0, 99.0),
(12.0, 98.0),
(30.0, 97.0),
(15.0, 96.0),
(25.0, 95.0),
(14.0, 94.0),
(14.0, 93.0),
];
let vals: Vec<f64> = bars
.iter()
.filter_map(|&(high, low)| td.update(c(high, low)))
.collect();
assert!(!vals.is_empty());
assert!(vals.iter().all(|&v| v == 1.0));
}
#[test]
fn same_direction_low_pivots_extend_one_leg() {
// Mirror of the high-pivot case: strictly increasing highs mean no bar
// is ever a high pivot, so the confirmed pivots are all lows. The
// `extends` else-branch fires (true at 2 < 5, false at 4 > 2).
let mut td = TdDWave::new(1).unwrap();
let bars = [
(100.0, 10.0),
(101.0, 5.0),
(102.0, 8.0),
(103.0, 2.0),
(104.0, 6.0),
(105.0, 4.0),
(106.0, 7.0),
(107.0, 7.0),
];
let vals: Vec<f64> = bars
.iter()
.filter_map(|&(high, low)| td.update(c(high, low)))
.collect();
assert!(!vals.is_empty());
assert!(vals.iter().all(|&v| v == 1.0));
}
#[test]
fn wave_stays_in_one_to_eight() {
let mut td = TdDWave::new(2).unwrap();
for v in td.batch(&zigzag()).into_iter().flatten() {
assert!((1.0..=8.0).contains(&v), "wave out of range: {v}");
}
}
#[test]
fn flat_input_never_counts() {
// A perfectly flat series has no distinct swing highs/lows.
let mut td = TdDWave::new(2).unwrap();
let candles: Vec<Candle> = (0..40).map(|_| c(100.0, 100.0)).collect();
assert!(td.batch(&candles).iter().all(Option::is_none));
}
#[test]
fn reset_clears_state() {
let mut td = TdDWave::new(2).unwrap();
td.batch(&zigzag());
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles = zigzag();
let batch = TdDWave::new(2).unwrap().batch(&candles);
let mut b = TdDWave::new(2).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,229 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Moving Averages — the ST1 (fast) and ST2 (slow) trend ribbon.
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`TdMovingAverage`]: the fast (`st1`) and slow (`st2`) moving-average
/// lines.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TdMovingAverageOutput {
/// ST1 — the fast (short) moving average.
pub st1: f64,
/// ST2 — the slow (long) moving average.
pub st2: f64,
}
/// Tom DeMark **TD Moving Averages** — a two-line trend ribbon (ST1 fast, ST2
/// slow) computed on the median price, whose relationship defines the trend.
///
/// ```text
/// price = (high + low) / 2 (median price)
/// st1 = SMA(price, period_st1) (fast / "Sequential Trend 1")
/// st2 = SMA(price, period_st2) (slow / "Sequential Trend 2")
/// ```
///
/// DeMark's moving-average pair frames the trend objectively: when `st1` is above
/// `st2` the trend is up, below it down, and the cross marks the change. Using the
/// **median price** rather than the close de-emphasises closing noise. This is a
/// streaming dual-SMA implementation of the ST1/ST2 ribbon; read the lines and
/// their crossover exactly as a fast/slow moving-average system.
///
/// `period_st1` must be strictly smaller than `period_st2`. The first value lands
/// once the slow average is seeded (`period_st2` inputs). Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TdMovingAverage};
///
/// let mut indicator = TdMovingAverage::new(5, 13).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TdMovingAverage {
st1: Sma,
st2: Sma,
period_st1: usize,
period_st2: usize,
last: Option<TdMovingAverageOutput>,
}
impl TdMovingAverage {
/// Construct TD Moving Averages with the given fast and slow periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, and
/// [`Error::InvalidPeriod`] if `period_st1 >= period_st2`.
pub fn new(period_st1: usize, period_st2: usize) -> Result<Self> {
if period_st1 == 0 || period_st2 == 0 {
return Err(Error::PeriodZero);
}
if period_st1 >= period_st2 {
return Err(Error::InvalidPeriod {
message: "TD moving average ST1 period must be strictly less than ST2",
});
}
Ok(Self {
st1: Sma::new(period_st1)?,
st2: Sma::new(period_st2)?,
period_st1,
period_st2,
last: None,
})
}
/// Configured `(period_st1, period_st2)`.
pub const fn periods(&self) -> (usize, usize) {
(self.period_st1, self.period_st2)
}
/// Current value if available.
pub const fn value(&self) -> Option<TdMovingAverageOutput> {
self.last
}
}
impl Indicator for TdMovingAverage {
type Input = Candle;
type Output = TdMovingAverageOutput;
fn update(&mut self, candle: Candle) -> Option<TdMovingAverageOutput> {
let price = candle.median_price();
let fast = self.st1.update(price);
let slow = self.st2.update(price);
if let (Some(st1), Some(st2)) = (fast, slow) {
let out = TdMovingAverageOutput { st1, st2 };
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.st1.reset();
self.st2.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period_st2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TDMovingAverage"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(median: f64) -> Candle {
Candle::new_unchecked(median, median + 1.0, median - 1.0, median, 1_000.0, 0)
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
TdMovingAverage::new(0, 13),
Err(Error::PeriodZero)
));
assert!(matches!(
TdMovingAverage::new(13, 5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
TdMovingAverage::new(5, 5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let td = TdMovingAverage::new(5, 13).unwrap();
assert_eq!(td.periods(), (5, 13));
assert_eq!(td.warmup_period(), 13);
assert_eq!(td.name(), "TDMovingAverage");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut td = TdMovingAverage::new(2, 4).unwrap();
let candles: Vec<Candle> = (0..8).map(|i| c(100.0 + f64::from(i))).collect();
let out = td.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn fast_leads_slow_in_uptrend() {
let mut td = TdMovingAverage::new(3, 7).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
let out = td.batch(&candles).into_iter().flatten().last().unwrap();
assert!(out.st1 > out.st2, "fast MA should lead in an uptrend");
}
#[test]
fn fast_below_slow_in_downtrend() {
let mut td = TdMovingAverage::new(3, 7).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| c(200.0 - f64::from(i))).collect();
let out = td.batch(&candles).into_iter().flatten().last().unwrap();
assert!(out.st1 < out.st2, "fast MA should trail in a downtrend");
}
#[test]
fn flat_series_equal_lines() {
let mut td = TdMovingAverage::new(2, 4).unwrap();
let out = td
.batch(&[c(50.0); 10])
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.st1, 50.0, epsilon = 1e-9);
assert_relative_eq!(out.st2, 50.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut td = TdMovingAverage::new(2, 4).unwrap();
td.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.value(), None);
assert_eq!(td.update(c(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = TdMovingAverage::new(5, 13).unwrap().batch(&candles);
let mut b = TdMovingAverage::new(5, 13).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,152 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Propulsion — a 2-bar trend-continuation thrust signal.
//!
//! TD Propulsion qualifies a continuation thrust: the bar opens on the trend side
//! of the prior close and then closes beyond the prior bar's extreme, "propelling"
//! the move forward.
//!
//! - **Propulsion up** (`+1.0`): `open >= close[-1]` (opens at or above the prior
//! close) AND `close > high[-1]` (closes above the prior high).
//! - **Propulsion down** (`-1.0`): `open <= close[-1]` AND `close < low[-1]`.
//! - Otherwise the output is `0.0`.
//!
//! The one-bar lookback means the first value lands on the second candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Propulsion — 2-bar trend-continuation thrust detector.
#[derive(Debug, Clone, Default)]
pub struct TdPropulsion {
prev: Option<Candle>,
last_value: Option<f64>,
}
impl TdPropulsion {
/// Construct a new `TdPropulsion`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdPropulsion {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let v = if candle.open >= prev.close && candle.close > prev.high {
1.0
} else if candle.open <= prev.close && candle.close < prev.low {
-1.0
} else {
0.0
};
self.prev = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDPropulsion"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(open, high, low, close, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdPropulsion::new();
assert_eq!(td.warmup_period(), 2);
assert_eq!(td.name(), "TDPropulsion");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut td = TdPropulsion::new();
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
assert!(td.update(c(10.5, 12.0, 10.0, 11.5)).is_some());
}
#[test]
fn propulsion_up() {
// prev close 10, high 11. Current open 10.5 >= 10, close 11.5 > 11 -> +1.
let mut td = TdPropulsion::new();
td.update(c(9.5, 11.0, 9.0, 10.0));
assert_eq!(td.update(c(10.5, 12.0, 10.0, 11.5)), Some(1.0));
}
#[test]
fn propulsion_down() {
// prev close 10, low 9. Current open 9.5 <= 10, close 8.5 < 9 -> -1.
let mut td = TdPropulsion::new();
td.update(c(10.5, 11.0, 9.0, 10.0));
assert_eq!(td.update(c(9.5, 10.0, 8.0, 8.5)), Some(-1.0));
}
#[test]
fn no_thrust_is_zero() {
let mut td = TdPropulsion::new();
td.update(c(9.5, 11.0, 9.0, 10.0));
// close 10.5 not above prior high 11 -> 0.
assert_eq!(td.update(c(10.5, 10.8, 10.0, 10.5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut td = TdPropulsion::new();
td.update(c(9.5, 11.0, 9.0, 10.0));
td.update(c(10.5, 12.0, 10.0, 11.5));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(9.5, 11.0, 9.0, 10.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
c(b, b + 1.0, b - 1.0, b + 0.3)
})
.collect();
let batch = TdPropulsion::new().batch(&candles);
let mut b = TdPropulsion::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,171 @@
#![allow(clippy::doc_markdown)]
//! Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout.
//!
//! A TD Trap forms when one bar is an **inside bar** (its high below and low above
//! the prior bar's), coiling the market; the next bar that closes beyond the trap
//! bar's high or low triggers the directional signal.
//!
//! - **Buy signal** (`+1.0`): the prior bar was an inside bar and the current
//! `close` is above that inside bar's `high`.
//! - **Sell signal** (`-1.0`): the prior bar was an inside bar and the current
//! `close` is below that inside bar's `low`.
//! - Otherwise the output is `0.0`.
//!
//! The two-bar lookback (one to set the inside bar, one before it) means the first
//! value lands on the third candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TD Trap — inside-bar breakout signal detector.
#[derive(Debug, Clone, Default)]
pub struct TdTrap {
prev1: Option<Candle>,
prev2: Option<Candle>,
last_value: Option<f64>,
}
impl TdTrap {
/// Construct a new `TdTrap`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Latest emitted signal if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for TdTrap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let (Some(trap), Some(before)) = (self.prev1, self.prev2) else {
// Not enough history yet: emit a neutral 0.0 while seeding.
self.prev2 = self.prev1;
self.prev1 = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let is_inside = trap.high < before.high && trap.low > before.low;
let v = if is_inside && candle.close > trap.high {
1.0
} else if is_inside && candle.close < trap.low {
-1.0
} else {
0.0
};
self.prev2 = self.prev1;
self.prev1 = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev1 = None;
self.prev2 = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TDTrap"
}
}
#[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, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let td = TdTrap::new();
assert_eq!(td.warmup_period(), 3);
assert_eq!(td.name(), "TDTrap");
assert!(!td.is_ready());
assert_eq!(td.value(), None);
}
#[test]
fn first_two_bars_seed_without_signal() {
let mut td = TdTrap::new();
assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
assert_eq!(td.update(c(108.0, 95.0, 102.0)), Some(0.0));
assert!(td.update(c(112.0, 100.0, 110.0)).is_some());
}
#[test]
fn inside_then_breakout_up_buys() {
// bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1.
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90)
assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0));
}
#[test]
fn inside_then_breakdown_sells() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0)); // inside bar
assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95
}
#[test]
fn no_inside_bar_is_zero() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside
assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0));
}
#[test]
fn inside_but_no_breakout_is_zero() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0)); // inside bar
assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108]
}
#[test]
fn reset_clears_state() {
let mut td = TdTrap::new();
td.update(c(110.0, 90.0, 100.0));
td.update(c(108.0, 95.0, 102.0));
td.update(c(112.0, 100.0, 109.0));
assert!(td.is_ready());
td.reset();
assert!(!td.is_ready());
assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
c(b + 2.0, b - 2.0, b)
})
.collect();
let batch = TdTrap::new().batch(&candles);
let mut b = TdTrap::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,192 @@
//! Time-Based Stop — a holding-period timer that fires after a fixed bar count.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of
/// price.
///
/// ```text
/// bars_held increments by 1 each bar (since the last reset)
/// progress = min(bars_held / max_bars, 1.0) in [0, 1]
/// stop fires when progress == 1.0 (bars_held >= max_bars)
/// ```
///
/// Some setups should not be given unlimited time to work: a mean-reversion entry
/// that has not reverted within `max_bars`, or an event trade whose catalyst has
/// passed, is best closed regardless of price. This indicator is a pure timer —
/// it ignores the candle's prices entirely and reports the fraction of the
/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars`
/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer
/// restarts from the position open.
///
/// Each `update` is O(1) and the first bar already emits a value
/// (`1 / max_bars`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TimeBasedStop};
///
/// let mut indicator = TimeBasedStop::new(5).unwrap();
/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap();
/// // Five bars reach the stop.
/// let mut last = 0.0;
/// for _ in 0..5 {
/// last = indicator.update(c).unwrap();
/// }
/// assert_eq!(last, 1.0);
/// ```
#[derive(Debug, Clone)]
pub struct TimeBasedStop {
max_bars: usize,
bars_held: usize,
last: Option<f64>,
}
impl TimeBasedStop {
/// Construct a time-based stop that fires after `max_bars` bars.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `max_bars == 0`.
pub fn new(max_bars: usize) -> Result<Self> {
if max_bars == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
max_bars,
bars_held: 0,
last: None,
})
}
/// Configured maximum holding period in bars.
pub const fn max_bars(&self) -> usize {
self.max_bars
}
/// Number of bars held since the last reset.
pub const fn bars_held(&self) -> usize {
self.bars_held
}
/// Whether the stop has fired (the holding period has fully elapsed).
pub const fn triggered(&self) -> bool {
self.bars_held >= self.max_bars
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TimeBasedStop {
type Input = Candle;
type Output = f64;
fn update(&mut self, _candle: Candle) -> Option<f64> {
self.bars_held += 1;
let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
self.last = Some(progress);
Some(progress)
}
fn reset(&mut self) {
self.bars_held = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TimeBasedStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c() -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
}
#[test]
fn rejects_zero_max_bars() {
assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let t = TimeBasedStop::new(5).unwrap();
assert_eq!(t.max_bars(), 5);
assert_eq!(t.bars_held(), 0);
assert!(!t.triggered());
assert_eq!(t.warmup_period(), 1);
assert_eq!(t.name(), "TimeBasedStop");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn progress_climbs_to_one() {
let mut t = TimeBasedStop::new(4).unwrap();
let out = t.batch(&[c(), c(), c(), c()]);
assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
}
#[test]
fn triggers_after_max_bars() {
let mut t = TimeBasedStop::new(3).unwrap();
t.update(c());
assert!(!t.triggered());
t.update(c());
assert!(!t.triggered());
t.update(c());
assert!(t.triggered());
}
#[test]
fn progress_saturates_at_one() {
// Beyond max_bars the progress stays clamped at 1.0.
let mut t = TimeBasedStop::new(2).unwrap();
let out = t.batch(&[c(), c(), c(), c()]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn reset_restarts_timer() {
let mut t = TimeBasedStop::new(3).unwrap();
t.batch(&[c(), c(), c()]);
assert!(t.triggered());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.bars_held(), 0);
assert!(!t.triggered());
assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles = [c(); 10];
let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
let mut b = TimeBasedStop::new(4).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,235 @@
//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Trade Volume Index — a cumulative line that adds volume while price ticks up
/// and subtracts it while price ticks down, where "up" and "down" are decided by
/// a **minimum tick value** rather than any change.
///
/// ```text
/// change = close prev_close
/// if change > min_tick: direction = +1
/// if change < min_tick: direction = 1
/// else: direction unchanged (price is "churning")
/// TVI_t = TVI_{t1} + direction * volume
/// ```
///
/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
/// flip the accumulation direction, so a price drifting within the spread keeps
/// adding volume in the last established direction instead of whipsawing. This is
/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
/// and applied to close-to-close moves. Like all cumulative lines, only its slope
/// and divergences against price carry meaning — the absolute level is arbitrary.
///
/// The first candle seeds the reference close and emits nothing; thereafter each
/// bar emits the running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
///
/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let close = 100.0 + f64::from(i);
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TradeVolumeIndex {
min_tick: f64,
prev_close: Option<f64>,
direction: f64,
tvi: f64,
last: Option<f64>,
}
impl TradeVolumeIndex {
/// Construct a new Trade Volume Index with the given minimum tick value.
///
/// # Errors
///
/// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
/// negative. A `min_tick` of `0` is allowed and makes every non-zero move
/// flip the direction.
pub fn new(min_tick: f64) -> Result<Self> {
if !min_tick.is_finite() || min_tick < 0.0 {
return Err(Error::InvalidParameter {
message: "trade volume index min_tick must be finite and non-negative",
});
}
Ok(Self {
min_tick,
prev_close: None,
direction: 0.0,
tvi: 0.0,
last: None,
})
}
/// Configured minimum tick value.
pub const fn min_tick(&self) -> f64 {
self.min_tick
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TradeVolumeIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let change = candle.close - prev_close;
if change > self.min_tick {
self.direction = 1.0;
} else if change < -self.min_tick {
self.direction = -1.0;
}
// Otherwise the direction is held from the previous bar (or 0 before the
// first decisive move), so a churning price keeps its last lean.
self.tvi += self.direction * candle.volume;
self.prev_close = Some(candle.close);
self.last = Some(self.tvi);
Some(self.tvi)
}
fn reset(&mut self) {
self.prev_close = None;
self.direction = 0.0;
self.tvi = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TradeVolumeIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_min_tick() {
assert!(matches!(
TradeVolumeIndex::new(-1.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
TradeVolumeIndex::new(f64::NAN),
Err(Error::InvalidParameter { .. })
));
assert!(TradeVolumeIndex::new(0.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let tvi = TradeVolumeIndex::new(0.25).unwrap();
assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
assert_eq!(tvi.warmup_period(), 2);
assert_eq!(tvi.name(), "TradeVolumeIndex");
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
}
#[test]
fn uptrend_accumulates_volume() {
// Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(101.0, 500.0), // +1 -> +500
candle(102.0, 300.0), // +1 -> +300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
}
#[test]
fn small_move_holds_last_direction() {
// After an up-move, a sub-tick wobble keeps adding in the up direction.
let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(102.0, 400.0), // +2 > tick -> dir +1, +400
candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
}
#[test]
fn downtrend_distributes_volume() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0),
candle(99.0, 200.0), // -1 -> -200
candle(98.0, 300.0), // -1 -> -300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
assert!(tvi.is_ready());
tvi.reset();
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
assert_eq!(tvi.update(candle(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
let mut b = TradeVolumeIndex::new(0.5).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,234 @@
//! Ehlers Trendflex — a trend-sensitive sibling of Reflex.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Trendflex** — the trend-sensitive companion to
/// [`Reflex`](crate::Reflex): it averages how far the SuperSmoothed price sits
/// above or below its values over the lookback, then self-normalises.
///
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
/// Feb 2020):
///
/// ```text
/// Filt = SuperSmoother(price, period)
/// sum = mean over i=1..period of ( Filt[0] Filt[i] )
/// ms = 0.04·sum² + 0.96·ms[1] (adaptive normaliser)
/// Trendflex = sum / sqrt(ms) (0 if ms == 0)
/// ```
///
/// Where Reflex measures deviation from the straight *line* across the window
/// (cycle sensitive, near zero lag), Trendflex measures deviation from the
/// window's *values* (trend sensitive). It stays pinned to one side of zero
/// during a trend and oscillates through zero in a range, so it doubles as a
/// trend/range gauge. The adaptive mean-square normaliser keeps the output near a
/// `±3` band on any instrument.
///
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
/// is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Trendflex};
///
/// let mut indicator = Trendflex::new(20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Trendflex {
period: usize,
smoother: SuperSmoother,
filt: VecDeque<f64>,
ms: f64,
last: Option<f64>,
}
impl Trendflex {
/// Construct a Trendflex with the given lookback `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoother: SuperSmoother::new(period)?,
filt: VecDeque::with_capacity(period + 1),
ms: 0.0,
last: None,
})
}
/// Configured lookback 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 Trendflex {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.smoother.update(price)?;
if self.filt.len() == self.period + 1 {
self.filt.pop_front();
}
self.filt.push_back(filt);
if self.filt.len() < self.period + 1 {
return None;
}
let newest = self.filt[self.period];
let mut sum = 0.0;
for i in 1..=self.period {
sum += newest - self.filt[self.period - i];
}
sum /= self.period as f64;
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
let trendflex = if self.ms > 0.0 {
sum / self.ms.sqrt()
} else {
0.0
};
self.last = Some(trendflex);
Some(trendflex)
}
fn reset(&mut self) {
self.smoother.reset();
self.filt.clear();
self.ms = 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 {
"Trendflex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Trendflex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let t = Trendflex::new(20).unwrap();
assert_eq!(t.period(), 20);
assert_eq!(t.warmup_period(), 21);
assert_eq!(t.name(), "Trendflex");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut t = Trendflex::new(5).unwrap();
let xs: Vec<f64> = (0..12).map(f64::from).collect();
let out = t.batch(&xs);
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn constant_input_is_zero() {
let mut t = Trendflex::new(10).unwrap();
for v in t.batch(&[50.0; 100]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn uptrend_is_positive() {
// A steady rise keeps the current filtered value above its past values.
let mut t = Trendflex::new(10).unwrap();
let out: Vec<f64> = t
.batch(&(0..200).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.skip(100)
.collect();
for v in out {
assert!(v > 0.0, "uptrend should be positive, got {v}");
}
}
#[test]
fn downtrend_is_negative() {
let mut t = Trendflex::new(10).unwrap();
let out: Vec<f64> = t
.batch(&(0..200).map(|i| 200.0 - f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.skip(100)
.collect();
for v in out {
assert!(v < 0.0, "downtrend should be negative, got {v}");
}
}
#[test]
fn ignores_non_finite() {
let mut t = Trendflex::new(10).unwrap();
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = t.value();
assert_eq!(t.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut t = Trendflex::new(10).unwrap();
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Trendflex::new(20).unwrap().batch(&xs);
let mut b = Trendflex::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,314 @@
//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
///
/// ```text
/// TRH = max(high, prev_close) (true high)
/// TRL = min(low, prev_close) (true low)
/// ad = volume * (2*close TRH TRL) / (TRH TRL) (0 if TRH == TRL)
/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period)
/// ```
///
/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
/// the bar's raw high/low with the *true* high/low (folding in the prior close so
/// gaps count), and it smooths the accumulated money flow and the volume with a
/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
/// reacts faster and never jumps when a large bar drops out of a window. The
/// output is bounded in roughly `[1, +1]`: positive means buying pressure
/// (closes biased toward the true high), negative means selling pressure.
///
/// The first candle seeds the reference close; the next `period` bars seed both
/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
/// of zero volume makes the denominator average `0`, in which case the oscillator
/// reports `0` rather than `0 / 0`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
///
/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TwiggsMoneyFlow {
period: usize,
prev_close: Option<f64>,
seed_ad: f64,
seed_vol: f64,
seed_count: usize,
ad_ema: Option<f64>,
vol_ema: Option<f64>,
last: Option<f64>,
}
impl TwiggsMoneyFlow {
/// Construct a new Twiggs Money Flow with the given smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_close: None,
seed_ad: 0.0,
seed_vol: 0.0,
seed_count: 0,
ad_ema: None,
vol_ema: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
if vol_ema == 0.0 {
0.0
} else {
ad_ema / vol_ema
}
}
}
impl Indicator for TwiggsMoneyFlow {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let trh = candle.high.max(prev_close);
let trl = candle.low.min(prev_close);
let range = trh - trl;
let ad = if range > 0.0 {
candle.volume * (2.0 * candle.close - trh - trl) / range
} else {
0.0
};
self.prev_close = Some(candle.close);
if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
let n = self.period as f64;
let new_ad = ad_ema + (ad - ad_ema) / n;
let new_vol = vol_ema + (candle.volume - vol_ema) / n;
self.ad_ema = Some(new_ad);
self.vol_ema = Some(new_vol);
let v = Self::ratio(new_ad, new_vol);
self.last = Some(v);
return Some(v);
}
self.seed_ad += ad;
self.seed_vol += candle.volume;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ad_ema = self.seed_ad / n;
let vol_ema = self.seed_vol / n;
self.ad_ema = Some(ad_ema);
self.vol_ema = Some(vol_ema);
let v = Self::ratio(ad_ema, vol_ema);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_ad = 0.0;
self.seed_vol = 0.0;
self.seed_count = 0;
self.ad_ema = None;
self.vol_ema = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TwiggsMoneyFlow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
}
#[test]
fn flat_bars_drive_tmf_to_zero() {
// A flat bar (high == low == close == prior close) gives a zero two-bar
// range, so the accumulation term falls back to 0.0 and TMF settles at
// zero. Exercises the `range == 0` guard.
let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
let flat: Vec<Candle> = (0..6)
.map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
.collect();
let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn accessors_and_metadata() {
let tmf = TwiggsMoneyFlow::new(21).unwrap();
assert_eq!(tmf.period(), 21);
assert_eq!(tmf.warmup_period(), 22);
assert_eq!(tmf.name(), "TwiggsMoneyFlow");
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
let out = tmf.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn closes_at_true_high_is_positive() {
// Every bar closes at its high -> strong buying pressure -> TMF -> +1.
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
// open=low=base-1, high=close=base+1 -> closes at the top.
Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last > 0.9,
"closing at the high should drive TMF near +1, got {last}"
);
}
#[test]
fn closes_at_true_low_is_negative() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 - f64::from(i);
// closes at the low.
Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < -0.5,
"closing at the low should drive TMF negative, got {last}"
);
}
#[test]
fn zero_volume_yields_zero() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 0.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_in_range() {
let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
tmf.batch(&candles);
assert!(tmf.is_ready());
tmf.reset();
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
})
.collect();
let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
let mut b = TwiggsMoneyFlow::new(21).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,254 @@
//! Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle.
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' **Universal Oscillator** — a cycle oscillator that whitens the price
/// series, SuperSmooths it, then normalises with an automatic gain control (AGC)
/// to swing in `[1, +1]`.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// WhiteNoise = (price_t price_{t2}) / 2 (flat-spectrum prewhitening)
/// Filt = SuperSmoother(WhiteNoise, period)
/// Peak = max(|Filt|, 0.991 · Peak_{t1}) (decaying peak / AGC)
/// Universal = Filt / Peak (0 if Peak == 0)
/// ```
///
/// "Whitening" the input (a two-bar difference) flattens its power spectrum so the
/// SuperSmoother responds equally to all cycles rather than being dominated by the
/// trend. The automatic gain control divides by a slowly-decaying running peak, so
/// the output is amplitude-normalised to `[1, +1]` and behaves consistently
/// across instruments and volatility regimes — hence "universal". Read it like any
/// bounded oscillator: turns near the rails flag cycle extremes, zero-crossings
/// flag cycle direction changes.
///
/// The first value lands once a two-bar difference exists (`warmup_period == 3`).
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, UniversalOscillator};
///
/// let mut indicator = UniversalOscillator::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 UniversalOscillator {
period: usize,
smoother: SuperSmoother,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
peak: f64,
last: Option<f64>,
}
impl UniversalOscillator {
/// Construct a Universal Oscillator with the given SuperSmoother `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoother: SuperSmoother::new(period)?,
prev_price_1: None,
prev_price_2: None,
peak: 0.0,
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 UniversalOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let Some(p2) = self.prev_price_2 else {
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
return None;
};
let white_noise = (price - p2) / 2.0;
if !white_noise.is_finite() {
// `price - p2` can overflow to +/-inf even when both are finite;
// skip the bar rather than feeding a non-finite value downstream.
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
return self.last;
}
let filt = self
.smoother
.update(white_noise)
.expect("supersmoother emits");
self.peak = filt.abs().max(0.991 * self.peak);
let universal = if self.peak > 0.0 {
(filt / self.peak).clamp(-1.0, 1.0)
} else {
0.0
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.last = Some(universal);
Some(universal)
}
fn reset(&mut self) {
self.smoother.reset();
self.prev_price_1 = None;
self.prev_price_2 = None;
self.peak = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"UniversalOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_period() {
assert!(matches!(
UniversalOscillator::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let u = UniversalOscillator::new(20).unwrap();
assert_eq!(u.period(), 20);
assert_eq!(u.warmup_period(), 3);
assert_eq!(u.name(), "UniversalOscillator");
assert!(!u.is_ready());
assert_eq!(u.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut u = UniversalOscillator::new(20).unwrap();
let out = u.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_some());
}
#[test]
fn constant_input_is_zero() {
// A flat input whitens to zero -> output 0.
let mut u = UniversalOscillator::new(20).unwrap();
for v in u.batch(&[50.0; 200]).into_iter().flatten() {
assert!(v.abs() < 1e-9);
}
}
#[test]
fn output_in_range() {
let mut u = UniversalOscillator::new(20).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
for v in u.batch(&xs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
}
}
#[test]
fn cyclic_input_swings_both_signs() {
let mut u = UniversalOscillator::new(20).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = u.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut u = UniversalOscillator::new(20).unwrap();
u.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = u.value();
assert_eq!(u.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut u = UniversalOscillator::new(20).unwrap();
u.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(u.is_ready());
u.reset();
assert!(!u.is_ready());
assert_eq!(u.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = UniversalOscillator::new(20).unwrap().batch(&xs);
let mut b = UniversalOscillator::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn non_finite_white_noise_is_skipped() {
// `price - p2` can overflow to infinity even when both prices are
// finite; the non-finite white-noise term must be skipped, not fed to
// the smoother (which would otherwise yield `None` on the first bar).
let mut u = UniversalOscillator::new(20).unwrap();
assert_eq!(u.update(-1e308), None);
assert_eq!(u.update(0.0), None);
// (1e308 - (-1e308)) overflows to +inf -> white_noise non-finite.
assert_eq!(u.update(1e308), None);
}
}
@@ -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);
}
}
@@ -0,0 +1,265 @@
//! Volume RSI — Wilder's RSI applied to the volume stream.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume RSI — the Relative Strength Index computed on **volume** changes
/// instead of price changes.
///
/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
/// change in volume:
///
/// ```text
/// change_t = volume_t volume_{t1}
/// gain = max(change, 0), loss = max(change, 0)
/// avg_gain, avg_loss = Wilder-smoothed over `period`
/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// Readings above `50` mean volume is expanding (more was added than removed over
/// the smoothing window) and tend to confirm the prevailing move; readings below
/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
/// of unchanged volume drives both averages to `0` and the indicator reports the
/// neutral `50` rather than an undefined `0 / 0`.
///
/// Only the candle's **volume** is used. The first bar sets the previous volume,
/// then `period` changes seed Wilder's averages, so the first value lands after
/// `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeRsi};
///
/// let mut indicator = VolumeRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeRsi {
period: usize,
prev_volume: Option<f64>,
seed_gains: f64,
seed_losses: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl VolumeRsi {
/// Construct a Volume RSI with the given Wilder smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_volume: None,
seed_gains: 0.0,
seed_losses: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
}
impl Indicator for VolumeRsi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let volume = candle.volume;
let Some(prev) = self.prev_volume else {
self.prev_volume = Some(volume);
return None;
};
let change = volume - prev;
self.prev_volume = Some(volume);
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gains += gain;
self.seed_losses += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ag = self.seed_gains / n;
let al = self.seed_losses / n;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_volume = None;
self.seed_gains = 0.0;
self.seed_losses = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle whose only material field here is `volume`.
fn vol_candle(volume: f64) -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = VolumeRsi::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.warmup_period(), 15);
assert_eq!(v.name(), "VolumeRsi");
assert!(!v.is_ready());
assert_eq!(v.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
let out = v.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn rising_volume_is_one_hundred() {
// Every change positive -> avg_loss 0 -> RSI 100.
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn falling_volume_is_zero() {
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40)
.map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
.collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn flat_volume_is_neutral() {
// Unchanged volume -> no gains and no losses -> neutral 50.
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut v = VolumeRsi::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
.collect();
for o in v.batch(&candles).into_iter().flatten() {
assert!((0.0..=100.0).contains(&o));
}
}
#[test]
fn reset_clears_state() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| vol_candle(1_000.0 + f64::from(i)))
.collect();
v.batch(&candles);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.value(), None);
assert_eq!(v.update(vol_candle(1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
.collect();
let batch = VolumeRsi::new(14).unwrap().batch(&candles);
let mut b = VolumeRsi::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,270 @@
//! Volume-Weighted MACD — MACD built on volume-weighted moving averages.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::vwma::Vwma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the
/// fast and slow averages volume-weighted.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolumeWeightedMacdOutput {
/// Fast VWMA slow VWMA.
pub macd: f64,
/// EMA of `macd` over the signal period.
pub signal: f64,
/// `macd signal`.
pub histogram: f64,
}
/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted**
/// moving averages instead of plain EMAs.
///
/// ```text
/// macd = VWMA(close, fast) VWMA(close, slow)
/// signal = EMA(macd, signal_period)
/// histogram = macd signal
/// ```
///
/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential
/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and
/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate
/// the trend estimate and the oscillator leans toward where real participation
/// occurred. Crossovers backed by volume therefore appear sooner and noise from
/// thin bars is damped. The signal line keeps a standard EMA, matching the
/// classic histogram construction.
///
/// `fast` must be strictly smaller than `slow`. The first output lands after
/// `slow + signal 1` inputs: `slow` to seed the slow VWMA, then `signal 1`
/// more to seed the signal EMA. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd};
///
/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeWeightedMacd {
fast: Vwma,
slow: Vwma,
signal_ema: Ema,
fast_period: usize,
slow_period: usize,
signal_period: usize,
last: Option<VolumeWeightedMacdOutput>,
}
impl VolumeWeightedMacd {
/// Construct a volume-weighted MACD with the given periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Vwma::new(fast)?,
slow: Vwma::new(slow)?,
signal_ema: Ema::new(signal)?,
fast_period: fast,
slow_period: slow,
signal_period: signal,
last: None,
})
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.signal_period)
}
/// Most recent fully-computed output if available.
pub const fn value(&self) -> Option<VolumeWeightedMacdOutput> {
self.last
}
}
impl Indicator for VolumeWeightedMacd {
type Input = Candle;
type Output = VolumeWeightedMacdOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedMacdOutput> {
let fast = self.fast.update(candle);
let slow = self.slow.update(candle);
if let (Some(f), Some(s)) = (fast, slow) {
let macd = f - s;
let signal = self.signal_ema.update(macd)?;
let out = VolumeWeightedMacdOutput {
macd,
signal,
histogram: macd - signal,
};
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal_ema.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.slow_period + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeWeightedMacd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
VolumeWeightedMacd::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
VolumeWeightedMacd::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolumeWeightedMacd::new(12, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = VolumeWeightedMacd::new(12, 26, 9).unwrap();
assert_eq!(m.periods(), (12, 26, 9));
assert_eq!(m.warmup_period(), 34);
assert_eq!(m.name(), "VolumeWeightedMacd");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let out = m.batch(&candles);
let warmup = m.warmup_period(); // 4 + 3 - 1 = 6
assert_eq!(warmup, 6);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_has_positive_macd() {
// A steady advance with equal volume -> fast VWMA leads slow -> macd > 0.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let last = m.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last.macd > 0.0,
"uptrend should give positive macd, got {}",
last.macd
);
}
#[test]
fn histogram_is_macd_minus_signal() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9);
}
}
#[test]
fn equal_volume_matches_plain_macd() {
// With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses
// SMA-based lines; it should still be a well-defined finite series.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0))
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert!(o.macd.is_finite() && o.signal.is_finite());
}
}
#[test]
fn reset_clears_state() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
m.batch(&candles);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(candle(100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.25).sin() * 9.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles);
let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,281 @@
//! Volume-Weighted Support/Resistance — a volume-weighted high/low band.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolumeWeightedSr`]: the volume-weighted support and resistance
/// levels over the lookback.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolumeWeightedSrOutput {
/// Volume-weighted average low — the support level.
pub support: f64,
/// Volume-weighted average high — the resistance level.
pub resistance: f64,
}
/// Volume-Weighted Support/Resistance — a band whose edges are the
/// **volume-weighted** average of the recent highs (resistance) and lows
/// (support), so the levels gravitate toward the prices where trading actually
/// happened.
///
/// ```text
/// support = Σ(low_i · volume_i) / Σ volume_i over the window
/// resistance = Σ(high_i · volume_i) / Σ volume_i over the window
/// ```
///
/// Plain high/low channels (e.g. [`Donchian`](crate::Donchian)) weight every bar
/// equally, so a thin spike sets the boundary. Volume-weighting pulls the support
/// and resistance toward the highs and lows that carried real volume — the prices
/// the market agreed mattered — giving levels that tend to hold better. The
/// distance between the two is a volume-aware range estimate. If the window's
/// volume is all zero the band falls back to the equal-weighted average high and
/// low.
///
/// The first value lands after `period` inputs; each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeWeightedSr};
///
/// let mut indicator = VolumeWeightedSr::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
/// let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeWeightedSr {
period: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
volumes: VecDeque<f64>,
sum_hv: f64,
sum_lv: f64,
sum_v: f64,
sum_h: f64,
sum_l: f64,
last: Option<VolumeWeightedSrOutput>,
}
impl VolumeWeightedSr {
/// Construct a volume-weighted S/R band over `period` bars.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
volumes: VecDeque::with_capacity(period),
sum_hv: 0.0,
sum_lv: 0.0,
sum_v: 0.0,
sum_h: 0.0,
sum_l: 0.0,
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<VolumeWeightedSrOutput> {
self.last
}
}
impl Indicator for VolumeWeightedSr {
type Input = Candle;
type Output = VolumeWeightedSrOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedSrOutput> {
if self.highs.len() == self.period {
let h = self.highs.pop_front().expect("non-empty");
let l = self.lows.pop_front().expect("non-empty");
let v = self.volumes.pop_front().expect("non-empty");
self.sum_hv -= h * v;
self.sum_lv -= l * v;
self.sum_v -= v;
self.sum_h -= h;
self.sum_l -= l;
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
self.volumes.push_back(candle.volume);
self.sum_hv += candle.high * candle.volume;
self.sum_lv += candle.low * candle.volume;
self.sum_v += candle.volume;
self.sum_h += candle.high;
self.sum_l += candle.low;
if self.highs.len() < self.period {
return None;
}
let n = self.period as f64;
let (support, resistance) = if self.sum_v > 0.0 {
(self.sum_lv / self.sum_v, self.sum_hv / self.sum_v)
} else {
(self.sum_l / n, self.sum_h / n)
};
let out = VolumeWeightedSrOutput {
support,
resistance,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
self.volumes.clear();
self.sum_hv = 0.0;
self.sum_lv = 0.0;
self.sum_v = 0.0;
self.sum_h = 0.0;
self.sum_l = 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 {
"VolumeWeightedSr"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolumeWeightedSr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = VolumeWeightedSr::new(20).unwrap();
assert_eq!(v.period(), 20);
assert_eq!(v.warmup_period(), 20);
assert_eq!(v.name(), "VolumeWeightedSr");
assert!(!v.is_ready());
assert_eq!(v.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut v = VolumeWeightedSr::new(4).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect();
let out = v.batch(&candles);
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn support_below_resistance() {
let mut v = VolumeWeightedSr::new(10).unwrap();
let candles: Vec<Candle> = (0..30)
.map(|i| {
c(
110.0 + (f64::from(i) * 0.3).sin() * 5.0,
90.0 + (f64::from(i) * 0.3).cos() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
for o in v.batch(&candles).into_iter().flatten() {
assert!(o.support <= o.resistance);
}
}
#[test]
fn weights_toward_high_volume_bars() {
// Three low-volume bars at [98,102] and one heavy bar at [108,112]; the
// resistance should be pulled toward the heavy bar's high.
let mut v = VolumeWeightedSr::new(4).unwrap();
let candles = [
c(102.0, 98.0, 100.0),
c(102.0, 98.0, 100.0),
c(102.0, 98.0, 100.0),
c(112.0, 108.0, 9_000.0),
];
let out = v.batch(&candles).into_iter().flatten().last().unwrap();
// Volume-weighted resistance sits much closer to 112 than the simple mean (104.5).
assert!(
out.resistance > 108.0,
"resistance {} should lean to the heavy bar",
out.resistance
);
}
#[test]
fn zero_volume_falls_back_to_equal_weight() {
let mut v = VolumeWeightedSr::new(3).unwrap();
let candles = [
c(102.0, 98.0, 0.0),
c(104.0, 96.0, 0.0),
c(106.0, 94.0, 0.0),
];
let out = v.batch(&candles).into_iter().flatten().last().unwrap();
// Equal-weight averages: high mean = 104, low mean = 96.
assert_relative_eq!(out.resistance, 104.0, epsilon = 1e-9);
assert_relative_eq!(out.support, 96.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut v = VolumeWeightedSr::new(4).unwrap();
v.batch(&(0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect::<Vec<_>>());
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.value(), None);
assert_eq!(v.update(c(102.0, 98.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
c(
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
90.0 + (f64::from(i) * 0.25).cos() * 9.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = VolumeWeightedSr::new(20).unwrap().batch(&candles);
let mut b = VolumeWeightedSr::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Williams Accumulation/Distribution — a cumulative price-only line that adds
/// the day's accumulation on up-closes and subtracts the day's distribution on
/// down-closes.
///
/// ```text
/// if close > prev_close: AD = close min(low, prev_close) (true low)
/// if close < prev_close: AD = close max(high, prev_close) (true high)
/// if close = prev_close: AD = 0
/// WAD_t = WAD_{t1} + AD
/// ```
///
/// Larry Williams' A/D line (distinct from Chaikin's volume-based
/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as
/// how far price closed above the *true low* on up-days and distribution as how
/// far it closed below the *true high* on down-days, then accumulates the result.
/// A rising WAD that diverges from a flat or falling price is the classic
/// accumulation signal; a falling WAD under a rising price warns of distribution.
///
/// The line is unbounded and its absolute level is meaningless — only its slope
/// and divergences against price matter. The first candle has no previous close,
/// so it seeds the reference and emits nothing; thereafter every bar emits the
/// running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Wad};
///
/// let mut indicator = Wad::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct Wad {
prev_close: Option<f64>,
line: f64,
last: Option<f64>,
}
impl Wad {
/// Construct a new Williams A/D line. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Wad {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let ad = if candle.close > prev_close {
candle.close - candle.low.min(prev_close)
} else if candle.close < prev_close {
candle.close - candle.high.max(prev_close)
} else {
0.0
};
self.line += ad;
self.prev_close = Some(candle.close);
self.last = Some(self.line);
Some(self.line)
}
fn reset(&mut self) {
self.prev_close = None;
self.line = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first bar only seeds the reference close; the first value lands on
// the second bar.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Wad"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
}
#[test]
fn accessors_and_metadata() {
let wad = Wad::new();
assert_eq!(wad.warmup_period(), 2);
assert_eq!(wad.name(), "Wad");
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut wad = Wad::new();
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some());
}
#[test]
fn up_close_accumulates() {
// close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100;
// AD = 101 - 100 = 1.
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap();
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
}
#[test]
fn down_close_distributes() {
// close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101;
// AD = 99 - 101 = -2.
let mut wad = Wad::new();
wad.update(candle(102.0, 100.0, 100.0));
let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap();
assert_relative_eq!(v, -2.0, epsilon = 1e-9);
}
#[test]
fn unchanged_close_adds_nothing() {
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn pure_uptrend_is_monotone() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for v in wad.batch(&candles).into_iter().flatten() {
assert!(v >= prev, "WAD must rise in an uptrend");
prev = v;
}
}
#[test]
fn reset_clears_state() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
wad.batch(&candles);
assert!(wad.is_ready());
wad.reset();
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
candle(base + 2.0, base - 2.0, base + 0.5)
})
.collect();
let batch = Wad::new().batch(&candles);
let mut b = Wad::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+87 -75
View File
@@ -57,88 +57,100 @@ pub use derivatives::DerivativesTick;
pub use error::{Error, Result};
pub use indicators::{
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
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,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCci, AdaptiveCycle,
AdaptiveLaguerreFilter, AdaptiveRsi, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio,
Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi,
AnchoredVwap, AndrewsPitchfork, AndrewsPitchforkOutput, Apo, Aroon, AroonOscillator,
AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput, AtrTrailingStop,
AutoFib, AutoFibOutput, Autocorrelation, AutocorrelationPeriodogram, AverageDailyRange,
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower,
BandpassFilter, Bat, BeltHold, Beta, BetaNeutralSpread, BetterVolume, BipowerVariation,
BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput,
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KasePermissionStochastic,
KasePermissionStochasticOutput, KellyCriterion, Keltner, KeltnerOutput, Kicking,
KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi,
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures,
LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom,
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted,
Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi,
Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, 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,
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, CentralPivotRange,
CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
Coppock, CorrelationTrendIndicator, 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, EvenBetterSinewave,
EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs,
FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension,
FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement,
FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput,
FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley,
GatorOscillator, GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket,
GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
HighWave, HighpassFilter, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility,
Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma,
JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop,
KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion,
Keltner, KeltnerOutput, KendallTau, 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, MurreyMathLines,
MurreyMathLinesOutput, 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,
PivotReversal, PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo,
PpoHistogram, ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar,
Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared,
RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, Reflex, RegimeLabel,
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
RollingCorrelation, RollingCovariance, RollingIqr, RollingMinMaxScaler, RollingPercentileRank,
RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput,
SampleEntropy, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput,
SessionRange, SessionRangeOutput, SessionVwap, ShannonEntropy, 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,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCamouflage, TdClop,
TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
TdMovingAverage, TdMovingAverageOutput, TdOpen, TdPressure, TdPropulsion, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile,
TpoProfileOutput, TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex,
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi,
Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows,
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UniversalOscillator,
UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone,
VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, VolumeWeightedSr,
VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
@@ -155,4 +167,4 @@ pub use indicators::PnfColumn;
pub use indicators::RenkoBrick;
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
pub use ohlcv::{Candle, Tick};
pub use traits::{BarBuilder, BatchExt, Chain, Indicator};
pub use traits::{BarBuilder, BatchExt, BatchNanExt, Chain, Indicator};
+34
View File
@@ -90,6 +90,29 @@ pub trait BatchExt: Indicator {
impl<T: Indicator> BatchExt for T {}
/// Fast batch for scalar `f64 -> f64` indicators.
///
/// The generic [`BatchExt::batch`] returns `Vec<Option<f64>>` — 16 bytes per
/// element (no niche fits an arbitrary `f64`), which a caller wanting a dense
/// `f64` series then has to walk a second time to map warmup `None`s to `NaN`.
/// This skips both the wide intermediate and the second pass: one allocation,
/// one pass, warmup encoded as `NaN`. The default body is bit-identical to
/// replaying `update`; indicators with a vectorizable closed form override it
/// with an inherent `batch_nan` of the same name, which wins method resolution
/// over this trait default.
pub trait BatchNanExt: Indicator<Input = f64, Output = f64> {
/// One `f64` per input, warmup positions filled with `NaN`.
fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
let mut out = Vec::with_capacity(inputs.len());
for &x in inputs {
out.push(self.update(x).unwrap_or(f64::NAN));
}
out
}
}
impl<T: Indicator<Input = f64, Output = f64>> BatchNanExt for T {}
/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi,
/// Point-and-Figure) that turns a candle stream into a stream of price-driven
/// bars.
@@ -297,6 +320,17 @@ mod tests {
assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]);
}
/// The blanket [`BatchNanExt::batch_nan`] default (used by every scalar
/// indicator without an inherent fast path) maps `update` outputs to a dense
/// `f64` series, warmup `None` becoming `NaN`. `Identity` is always ready, so
/// the result is just the inputs back.
#[test]
fn batch_nan_default_maps_none_to_nan() {
let mut id = Identity::default();
let out = id.batch_nan(&[1.0, 2.0, 3.0]);
assert_eq!(out, vec![1.0, 2.0, 3.0]);
}
#[test]
fn chain_pipes_first_into_second() {
let mut c = Chain::new(Doubler::default(), Doubler::default());
+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 **474 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.7",
"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.7",
"wickra-darwin-x64": "0.6.7",
"wickra-linux-arm64-gnu": "0.6.7",
"wickra-linux-x64-gnu": "0.6.7",
"wickra-win32-arm64-msvc": "0.6.7",
"wickra-win32-x64-msvc": "0.6.7"
}
},
"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
+39 -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, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, 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, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, 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,9 +115,17 @@ 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);
drive(|| JarqueBera::new(20).unwrap(), &data);
drive(|| RollingMinMaxScaler::new(20).unwrap(), &data);
drive(|| ShannonEntropy::new(20, 8).unwrap(), &data);
drive(|| SampleEntropy::new(20, 2, 0.2).unwrap(), &data);
drive(|| TrendLabel::new(14).unwrap(), &data);
drive(|| JumpIndicator::new(20, 3.0).unwrap(), &data);
drive(|| RegimeLabel::new(5, 20).unwrap(), &data);
@@ -171,6 +179,15 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| CyberneticCycle::new(10).unwrap(), &data);
drive(|| InstantaneousTrendline::new(20).unwrap(), &data);
drive(|| EhlersStochastic::new(20).unwrap(), &data);
drive(|| HighpassFilter::new(48).unwrap(), &data);
drive(|| Reflex::new(20).unwrap(), &data);
drive(|| Trendflex::new(20).unwrap(), &data);
drive(|| CorrelationTrendIndicator::new(20).unwrap(), &data);
drive(|| AdaptiveRsi::new(14).unwrap(), &data);
drive(|| UniversalOscillator::new(20).unwrap(), &data);
drive(|| BandpassFilter::new(20, 0.3).unwrap(), &data);
drive(|| EvenBetterSinewave::new(40, 10).unwrap(), &data);
drive(|| AutocorrelationPeriodogram::new(10, 48).unwrap(), &data);
drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data);
drive(HilbertDominantCycle::new, &data);
drive(HtDcPhase::new, &data);
@@ -268,6 +285,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 {
+33 -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, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, CentralPivotRange, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, MurreyMathLines, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PivotReversal, 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, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure, TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, VolumeWeightedSr, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -62,6 +62,7 @@ fuzz_target!(|data: Vec<f64>| {
}
// --- Volatility & ATR family ---
drive(|| VolatilityRatio::new(14).unwrap(), &candles);
drive(|| Atr::new(14).unwrap(), &candles);
drive(|| Natr::new(14).unwrap(), &candles);
drive(TrueRange::new, &candles);
@@ -72,10 +73,17 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| YangZhangVolatility::new(20, 252).unwrap(), &candles);
// --- Bands & Channels ---
drive(|| ProjectionOscillator::new(14).unwrap(), &candles);
drive(|| ProjectionBands::new(3).unwrap(), &candles);
drive(|| Keltner::new(20, 10, 2.0).unwrap(), &candles);
drive(|| Donchian::new(20).unwrap(), &candles);
// --- Trailing Stops ---
drive(|| ModifiedMaStop::new(14).unwrap(), &candles);
drive(|| TimeBasedStop::new(5).unwrap(), &candles);
drive(|| Nrtr::new(2.0).unwrap(), &candles);
drive(|| AtrRatchet::new(14, 4.0, 0.1).unwrap(), &candles);
drive(|| ElderSafeZone::new(14, 2.0).unwrap(), &candles);
drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles);
drive(SarExt::classic, &candles);
drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles);
@@ -85,6 +93,7 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| HiLoActivator::new(3).unwrap(), &candles);
drive(|| VoltyStop::new(14, 2.0).unwrap(), &candles);
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
drive(|| KaseDevStop::new(30, 1.0).unwrap(), &candles);
// --- Trend & Directional ---
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
@@ -109,6 +118,7 @@ fuzz_target!(|data: Vec<f64>| {
// --- Momentum & Oscillators ---
drive(|| Cci::new(20).unwrap(), &candles);
drive(|| AdaptiveCci::new(20).unwrap(), &candles);
drive(|| StochasticCci::new(14).unwrap(), &candles);
drive(|| ElderRay::new(13).unwrap(), &candles);
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
@@ -131,6 +141,13 @@ fuzz_target!(|data: Vec<f64>| {
drive(HighLowRange::new, &candles);
// --- Volume ---
drive(|| BetterVolume::new(14).unwrap(), &candles);
drive(IntradayIntensity::new, &candles);
drive(|| TradeVolumeIndex::new(0.25).unwrap(), &candles);
drive(|| TwiggsMoneyFlow::new(21).unwrap(), &candles);
drive(Wad::new, &candles);
drive(|| VolumeRsi::new(14).unwrap(), &candles);
drive(|| VolumeWeightedMacd::new(12, 26, 9).unwrap(), &candles);
drive(Obv::new, &candles);
drive(|| Mfi::new(14).unwrap(), &candles);
drive(Vwap::new, &candles);
@@ -246,6 +263,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 {
@@ -297,6 +315,13 @@ fuzz_target!(|data: Vec<f64>| {
}
// --- Candlestick Patterns (family 14) ---
drive(TdTrap::new, &candles);
drive(TdPropulsion::new, &candles);
drive(TdClopwin::new, &candles);
drive(TdClop::new, &candles);
drive(TdCamouflage::new, &candles);
drive(|| TdDWave::new(2).unwrap(), &candles);
drive(|| TdMovingAverage::new(5, 13).unwrap(), &candles);
drive(ConcealingBabySwallow::new, &candles);
drive(UniqueThreeRiver::new, &candles);
drive(TasukiGap::new, &candles);
@@ -415,4 +440,11 @@ fuzz_target!(|data: Vec<f64>| {
drive(FibExtension::new, &candles);
drive(FibRetracement::new, &candles);
// --- Pivots & S/R ---
drive(CentralPivotRange::new, &candles);
drive(|| MurreyMathLines::new(4).unwrap(), &candles);
drive(|| AndrewsPitchfork::new(2).unwrap(), &candles);
drive(|| VolumeWeightedSr::new(3).unwrap(), &candles);
drive(|| PivotReversal::new(1, 1).unwrap(), &candles);
});
+2 -1
View File
@@ -8,7 +8,7 @@
//! panic.
use libfuzzer_sys::fuzz_target;
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, KendallTau, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
@@ -47,6 +47,7 @@ fuzz_target!(|data: &[u8]| {
drive(|| VarianceRatio::new(60, 2).unwrap(), &pairs);
drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs);
drive(|| SpreadAr1Coefficient::new(40).unwrap(), &pairs);
drive(|| KendallTau::new(20).unwrap(), &pairs);
// Struct-output pair indicator: drive update + batch directly (the generic
// `drive` above only covers `Output = f64`).