Compare commits

...

3 Commits

Author SHA1 Message Date
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
41 changed files with 4068 additions and 390 deletions
+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 | **★ 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 | **★ 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 | **★ 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
```
+11 -1
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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`).
@@ -1344,7 +1353,8 @@ 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.6.5...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.6...HEAD
[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
Generated
+8 -8
View File
@@ -1944,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"approx",
"criterion",
@@ -1955,7 +1955,7 @@ dependencies = [
[[package]]
name = "wickra-bench"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"criterion",
"kand",
@@ -1967,7 +1967,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"approx",
"proptest",
@@ -1977,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"approx",
"csv",
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"serde_json",
"tokio",
@@ -2004,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"napi",
"napi-build",
@@ -2014,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"numpy",
"pyo3",
@@ -2023,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -13,7 +13,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.6.5"
version = "0.6.6"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -25,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.6.5" }
wickra-core = { path = "crates/wickra-core", version = "0.6.6" }
thiserror = "2"
rayon = "1.10"
+54 -114
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=462" 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=467" 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 462 indicators; start at the
every one of the 467 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,19 +58,44 @@ 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 exists
## Why Wickra
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.
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.
Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
- **The biggest streaming-native catalogue, period.** 467 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 467 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** | **423** | **yes** |
| **★&nbsp;Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **467** | **yes** |
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
| ta-rs | clean | yes | Rust only | ~30 | stale |
| yata | clean | partial | Rust only | ~35 | yes |
@@ -79,116 +104,31 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
Wickra's edge is **breadth with reach**: 462 indicators that all update in O(1)
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
single engine.
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
the same time. That's the combination no one else ships.
**On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
leaner Rust crates (kand, ta-rs) win several of the micro-benchmarks below, and
those losses are shown rather than hidden. The gap is a *choice*, not a ceiling:
every `update` validates its input, runs a real warmup before it emits a value,
and returns an `Option` so a single bad tick can't silently poison the state.
ta-rs, by contrast, hands back a bare `f64` from the first tick with no
validation. If Wickra threw all of that away — raw `f64` out, no checks, no
warmup contract — it would match or beat the leanest crate on every row. It
keeps the guarantees instead, and still wins RSI, Bollinger and ATR against kand.
What no other library matches is the *combination*: catalogue size, native O(1)
streaming, NaN-safety, and four first-class language targets at once.
## Why Wickra exists
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.
## Benchmarks
Three comparisons, split by layer and mode. Read them as **relative** speedups
on identical input — absolute µs depend on CPU, memory clock and OS scheduler,
not a universal contract.
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`.
- **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. Rust core vs the other Rust TA crates
Like-for-like, no language-binding overhead, over a 50 000-bar series (µs for
the whole series, lower = faster). This is the honest engine comparison —
Wickra wins some and loses some, and both are shown.
**Streaming** (one value fed per `update`):
| 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 | — |
**Batch** (whole series at once). Only Wickra and kand expose a batch API;
ta-rs and yata are streaming-only.
| Indicator | **★&nbsp;Wickra** | kand |
|------------------|------------------:|-----:|
| SMA(20) | 82 | 42 |
| EMA(20) | 159 | 74 |
| RSI(14) | **253 ★** | 274 |
| MACD(12, 26, 9) | 681 | 283 |
| Bollinger(20, 2) | **445 ★** | 462 |
| ATR(14) | 175 | 173 |
ta-rs is the per-indicator speed champion on almost every row — it returns a
bare `f64` with no warmup state and no input validation, trading away the
`None`-warmup and NaN-safety semantics Wickra keeps. Against kand, Wickra wins
streaming RSI, Bollinger and ATR (and batch RSI + Bollinger); Bollinger is the
one row where Wickra is the outright fastest of all four. The leaner crates
still win the pure recurrences (EMA, MACD) and SMA. yata exposes only SMA/EMA as
raw-value methods, so its other rows are omitted rather than faked.
### 2. Python vs the Python TA ecosystem — batch
Full pass over a 20 000-bar series, µs/op (lower = faster). **★** per row.
| Indicator | **★&nbsp;Wickra** | finta | TA-Lib | tulipy |
|------------------|------------------:|---------------------|--------|--------|
| SMA(20) | **59.6 ★** | 354.2 (5.9× slower) | ⧗ | ⧗ |
| EMA(20) | **88.4 ★** | 309.3 (3.5× slower) | ⧗ | ⧗ |
| RSI(14) | **77.3 ★** | 1 283 (16.6× slower)| ⧗ | ⧗ |
| MACD(12, 26, 9) | **116.4 ★** | 529.5 (4.6× slower) | ⧗ | ⧗ |
| Bollinger(20, 2) | **146.0 ★** | 1 246 (8.5× slower) | ⧗ | ⧗ |
| ATR(14) | **135.8 ★** | 3 812 (28× slower) | ⧗ | ⧗ |
> ⧗ = published by the CI Linux job. TA-Lib and tulipy ship C extensions that
> don't build cleanly on every desktop, so their canonical numbers come from the
> `cross-library-bench` workflow rather than this local table. pandas-ta needs
> Python ≥ 3.12 and isn't in the 3.11 CI matrix. The script auto-detects
> whichever peers are installed in your environment.
### 3. Python — streaming (per-tick latency)
Seed 5 000 bars, then feed ticks one at a time. talipp is the only Python peer
with a true incremental API; batch-only libraries like TA-Lib must recompute the
entire history on every tick — Wickra updates in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|------------------|------------------------------:|-------------------------|
| SMA(20) | **0.067 µs ★** | 0.63 µs (9.4× slower) |
| EMA(20) | **0.051 µs ★** | 0.63 µs (12.2× slower) |
| RSI(14) | **0.053 µs ★** | 1.00 µs (19.1× slower) |
| MACD(12, 26, 9) | **0.071 µs ★** | 3.64 µs (51.5× slower) |
| Bollinger(20, 2) | **0.085 µs ★** | 4.87 µs (57.2× slower) |
Run the suite yourself:
```bash
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
```
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
**[BENCHMARKS.md](BENCHMARKS.md)**.
## Indicators
462 streaming-first indicators across twenty-four families. Every one passes the
467 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).
@@ -205,7 +145,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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 |
| 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 |
| 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) |
@@ -297,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 462 indicators
│ ├── wickra-core/ core engine + all 467 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
@@ -377,6 +377,7 @@ const candleScalar = {
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) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -474,6 +475,10 @@ const multi = {
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) },
};
for (const [name, d] of Object.entries(multi)) {
+70
View File
@@ -239,6 +239,31 @@ export interface ProjectionBandsValue {
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
@@ -2930,6 +2955,51 @@ export declare class ProjectionBands {
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)
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.6.5",
"version": "0.6.6",
"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.6.5",
"version": "0.6.6",
"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.6.5",
"version": "0.6.6",
"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.6.5",
"version": "0.6.6",
"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.6.5",
"version": "0.6.6",
"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.6.5",
"version": "0.6.6",
"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.6.5",
"version": "0.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.6.5",
"version": "0.6.6",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.6.5",
"wickra-darwin-x64": "0.6.5",
"wickra-linux-arm64-gnu": "0.6.5",
"wickra-linux-x64-gnu": "0.6.5",
"wickra-win32-arm64-msvc": "0.6.5",
"wickra-win32-x64-msvc": "0.6.5"
"wickra-darwin-arm64": "0.6.6",
"wickra-darwin-x64": "0.6.6",
"wickra-linux-arm64-gnu": "0.6.6",
"wickra-linux-x64-gnu": "0.6.6",
"wickra-win32-arm64-msvc": "0.6.6",
"wickra-win32-x64-msvc": "0.6.6"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.5.tgz",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.6.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.5.tgz",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.6.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.5.tgz",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.6.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.5.tgz",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.6.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.5.tgz",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.6.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.5.tgz",
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.6.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.6.5",
"version": "0.6.6",
"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.6.5",
"wickra-linux-arm64-gnu": "0.6.5",
"wickra-darwin-x64": "0.6.5",
"wickra-darwin-arm64": "0.6.5",
"wickra-win32-x64-msvc": "0.6.5",
"wickra-win32-arm64-msvc": "0.6.5"
"wickra-linux-x64-gnu": "0.6.6",
"wickra-linux-arm64-gnu": "0.6.6",
"wickra-darwin-x64": "0.6.6",
"wickra-darwin-arm64": "0.6.6",
"wickra-win32-x64-msvc": "0.6.6",
"wickra-win32-arm64-msvc": "0.6.6"
},
"scripts": {
"build": "napi build --platform --release",
+365
View File
@@ -9491,6 +9491,371 @@ impl ProjectionBandsNode {
}
}
// ---------- Central Pivot Range ----------
#[napi(object)]
pub struct CentralPivotRangeValue {
pub pivot: f64,
pub tc: f64,
pub bc: f64,
}
#[napi(js_name = "CentralPivotRange")]
pub struct CentralPivotRangeNode {
inner: wc::CentralPivotRange,
}
impl Default for CentralPivotRangeNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl CentralPivotRangeNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::CentralPivotRange::new(),
}
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<CentralPivotRangeValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| CentralPivotRangeValue {
pivot: o.pivot,
tc: o.tc,
bc: o.bc,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 3] = o.pivot;
out[i * 3 + 1] = o.tc;
out[i * 3 + 2] = o.bc;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Murrey Math Lines ----------
#[napi(object)]
pub struct MurreyMathLinesValue {
#[napi(js_name = "mm8_8")]
pub mm8_8: f64,
#[napi(js_name = "mm7_8")]
pub mm7_8: f64,
#[napi(js_name = "mm6_8")]
pub mm6_8: f64,
#[napi(js_name = "mm5_8")]
pub mm5_8: f64,
#[napi(js_name = "mm4_8")]
pub mm4_8: f64,
#[napi(js_name = "mm3_8")]
pub mm3_8: f64,
#[napi(js_name = "mm2_8")]
pub mm2_8: f64,
#[napi(js_name = "mm1_8")]
pub mm1_8: f64,
#[napi(js_name = "mm0_8")]
pub mm0_8: f64,
}
#[napi(js_name = "MurreyMathLines")]
pub struct MurreyMathLinesNode {
inner: wc::MurreyMathLines,
}
#[napi]
impl MurreyMathLinesNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MurreyMathLines::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<MurreyMathLinesValue>> {
Ok(self
.inner
.update(cnd(high, low, low, 0.0)?)
.map(|o| MurreyMathLinesValue {
mm8_8: o.mm8_8,
mm7_8: o.mm7_8,
mm6_8: o.mm6_8,
mm5_8: o.mm5_8,
mm4_8: o.mm4_8,
mm3_8: o.mm3_8,
mm2_8: o.mm2_8,
mm1_8: o.mm1_8,
mm0_8: o.mm0_8,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 9];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 9] = o.mm8_8;
out[i * 9 + 1] = o.mm7_8;
out[i * 9 + 2] = o.mm6_8;
out[i * 9 + 3] = o.mm5_8;
out[i * 9 + 4] = o.mm4_8;
out[i * 9 + 5] = o.mm3_8;
out[i * 9 + 6] = o.mm2_8;
out[i * 9 + 7] = o.mm1_8;
out[i * 9 + 8] = o.mm0_8;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Andrews Pitchfork ----------
#[napi(object)]
pub struct AndrewsPitchforkValue {
pub median: f64,
pub upper: f64,
pub lower: f64,
}
#[napi(js_name = "AndrewsPitchfork")]
pub struct AndrewsPitchforkNode {
inner: wc::AndrewsPitchfork,
}
#[napi]
impl AndrewsPitchforkNode {
#[napi(constructor)]
pub fn new(strength: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AndrewsPitchfork::new(strength as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<AndrewsPitchforkValue>> {
Ok(self
.inner
.update(cnd(high, low, low, 0.0)?)
.map(|o| AndrewsPitchforkValue {
median: o.median,
upper: o.upper,
lower: o.lower,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 3] = o.median;
out[i * 3 + 1] = o.upper;
out[i * 3 + 2] = o.lower;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Volume-Weighted S/R ----------
#[napi(object)]
pub struct VolumeWeightedSrValue {
pub support: f64,
pub resistance: f64,
}
#[napi(js_name = "VolumeWeightedSr")]
pub struct VolumeWeightedSrNode {
inner: wc::VolumeWeightedSr,
}
#[napi]
impl VolumeWeightedSrNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeWeightedSr::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
volume: f64,
) -> napi::Result<Option<VolumeWeightedSrValue>> {
Ok(self
.inner
.update(cnd(high, low, low, volume)?)
.map(|o| VolumeWeightedSrValue {
support: o.support,
resistance: o.resistance,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, volume must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], volume[i])?) {
out[i * 2] = o.support;
out[i * 2 + 1] = o.resistance;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Pivot Reversal ----------
#[napi(js_name = "PivotReversal")]
pub struct PivotReversalNode {
inner: wc::PivotReversal,
}
#[napi]
impl PivotReversalNode {
#[napi(constructor)]
pub fn new(left: u32, right: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PivotReversal::new(left as usize, right as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Double Bollinger ----------
#[napi(object)]
+217 -16
View File
@@ -72,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:
@@ -457,6 +467,161 @@ def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[C
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
# --------------------------------------------------------------------------- #
@@ -519,36 +684,54 @@ 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
@@ -558,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:
@@ -565,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:]
@@ -581,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)
@@ -629,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,
@@ -641,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()
@@ -660,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__":
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.6.5"
version = "0.6.6"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+10
View File
@@ -309,6 +309,11 @@ from ._wickra import (
FractalChaosBands,
VwapStdDevBands,
# Pivots & S/R
PivotReversal,
VolumeWeightedSr,
AndrewsPitchfork,
MurreyMathLines,
CentralPivotRange,
ClassicPivots,
FibonacciPivots,
Camarilla,
@@ -801,6 +806,11 @@ __all__ = [
"FractalChaosBands",
"VwapStdDevBands",
# Pivots & S/R
"PivotReversal",
"VolumeWeightedSr",
"AndrewsPitchfork",
"MurreyMathLines",
"CentralPivotRange",
"ClassicPivots",
"FibonacciPivots",
"Camarilla",
+501 -163
View File
File diff suppressed because it is too large Load Diff
@@ -382,6 +382,10 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"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),
@@ -948,6 +952,26 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"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),
@@ -3112,6 +3136,44 @@ def test_volume_weighted_macd_reference():
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)
# --- Lifecycle ------------------------------------------------------------
+302
View File
@@ -6441,6 +6441,308 @@ impl WasmProjectionBands {
}
}
// ---------- Central Pivot Range (high/low/close input, 3 outputs) ----------
#[wasm_bindgen(js_name = CentralPivotRange)]
pub struct WasmCentralPivotRange {
inner: wc::CentralPivotRange,
}
impl Default for WasmCentralPivotRange {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = CentralPivotRange)]
impl WasmCentralPivotRange {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmCentralPivotRange {
Self {
inner: wc::CentralPivotRange::new(),
}
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, close, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"pivot".into(), &o.pivot.into()).ok();
Reflect::set(&obj, &"tc".into(), &o.tc.into()).ok();
Reflect::set(&obj, &"bc".into(), &o.bc.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.pivot;
out[i * 3 + 1] = o.tc;
out[i * 3 + 2] = o.bc;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Murrey Math Lines (high/low input, 9 outputs) ----------
#[wasm_bindgen(js_name = MurreyMathLines)]
pub struct WasmMurreyMathLines {
inner: wc::MurreyMathLines,
}
#[wasm_bindgen(js_class = MurreyMathLines)]
impl WasmMurreyMathLines {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmMurreyMathLines, JsError> {
Ok(Self {
inner: wc::MurreyMathLines::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"mm8_8".into(), &o.mm8_8.into()).ok();
Reflect::set(&obj, &"mm7_8".into(), &o.mm7_8.into()).ok();
Reflect::set(&obj, &"mm6_8".into(), &o.mm6_8.into()).ok();
Reflect::set(&obj, &"mm5_8".into(), &o.mm5_8.into()).ok();
Reflect::set(&obj, &"mm4_8".into(), &o.mm4_8.into()).ok();
Reflect::set(&obj, &"mm3_8".into(), &o.mm3_8.into()).ok();
Reflect::set(&obj, &"mm2_8".into(), &o.mm2_8.into()).ok();
Reflect::set(&obj, &"mm1_8".into(), &o.mm1_8.into()).ok();
Reflect::set(&obj, &"mm0_8".into(), &o.mm0_8.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high and low must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 9];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 9] = o.mm8_8;
out[i * 9 + 1] = o.mm7_8;
out[i * 9 + 2] = o.mm6_8;
out[i * 9 + 3] = o.mm5_8;
out[i * 9 + 4] = o.mm4_8;
out[i * 9 + 5] = o.mm3_8;
out[i * 9 + 6] = o.mm2_8;
out[i * 9 + 7] = o.mm1_8;
out[i * 9 + 8] = o.mm0_8;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Andrews Pitchfork (high/low input, 3 outputs) ----------
#[wasm_bindgen(js_name = AndrewsPitchfork)]
pub struct WasmAndrewsPitchfork {
inner: wc::AndrewsPitchfork,
}
#[wasm_bindgen(js_class = AndrewsPitchfork)]
impl WasmAndrewsPitchfork {
#[wasm_bindgen(constructor)]
pub fn new(strength: usize) -> Result<WasmAndrewsPitchfork, JsError> {
Ok(Self {
inner: wc::AndrewsPitchfork::new(strength).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"median".into(), &o.median.into()).ok();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high and low must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.median;
out[i * 3 + 1] = o.upper;
out[i * 3 + 2] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Volume-Weighted S/R (high/low/volume input, 2 outputs) ----------
#[wasm_bindgen(js_name = VolumeWeightedSr)]
pub struct WasmVolumeWeightedSr {
inner: wc::VolumeWeightedSr,
}
#[wasm_bindgen(js_class = VolumeWeightedSr)]
impl WasmVolumeWeightedSr {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVolumeWeightedSr, JsError> {
Ok(Self {
inner: wc::VolumeWeightedSr::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, volume)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"support".into(), &o.support.into()).ok();
Reflect::set(&obj, &"resistance".into(), &o.resistance.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(JsError::new("high, low, volume must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], volume[i])?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.support;
out[i * 2 + 1] = o.resistance;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Pivot Reversal (high/low/close input, scalar signal) ----------
#[wasm_bindgen(js_name = PivotReversal)]
pub struct WasmPivotReversal {
inner: wc::PivotReversal,
}
#[wasm_bindgen(js_class = PivotReversal)]
impl WasmPivotReversal {
#[wasm_bindgen(constructor)]
pub fn new(left: usize, right: usize) -> Result<WasmPivotReversal, JsError> {
Ok(Self {
inner: wc::PivotReversal::new(left, right).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let candle = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(candle))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let candle = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- Double Bollinger (scalar input, 5 outputs) ----------
#[wasm_bindgen(js_name = DoubleBollinger)]
+11 -7
View File
@@ -25,7 +25,7 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wickra::{Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
use wickra::{Atr, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
use wickra_data::csv::CandleReader;
use yata::prelude::Method;
@@ -82,7 +82,7 @@ fn sma_group(crit: &mut Criterion, closes: &[f64]) {
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
black_box(ind.batch(series));
black_box(ind.batch_nan(series));
});
},
);
@@ -170,7 +170,7 @@ fn ema_group(crit: &mut Criterion, closes: &[f64]) {
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
black_box(ind.batch(series));
black_box(ind.batch_nan(series));
});
},
);
@@ -253,7 +253,7 @@ fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
black_box(ind.batch(series));
black_box(ind.batch_nan(series));
});
},
);
@@ -352,7 +352,7 @@ fn macd_group(crit: &mut Criterion, closes: &[f64]) {
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
black_box(ind.batch(series));
black_box(ind.batch_macd(series));
});
},
);
@@ -478,7 +478,7 @@ fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
black_box(ind.batch(series));
black_box(ind.batch_bands(series));
});
},
);
@@ -604,9 +604,13 @@ fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
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(series));
black_box(ind.batch_atr(&high, &low, &close));
});
},
);
@@ -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);
}
}
+136
View File
@@ -75,6 +75,67 @@ impl Atr {
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
}
}
impl Indicator for Atr {
@@ -266,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]
@@ -108,6 +108,82 @@ 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.count != self.period {
return None;
@@ -352,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,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);
}
}
+127
View File
@@ -102,6 +102,68 @@ impl Ema {
}
}
/// 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> {
@@ -288,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]
+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();
+16 -1
View File
@@ -32,6 +32,7 @@ mod alpha;
mod amihud_illiquidity;
mod anchored_rsi;
mod anchored_vwap;
mod andrews_pitchfork;
mod apo;
mod aroon;
mod aroon_oscillator;
@@ -68,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;
@@ -257,6 +259,7 @@ 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;
@@ -286,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;
@@ -446,6 +450,7 @@ mod volume_oscillator;
mod volume_profile;
mod volume_rsi;
mod volume_weighted_macd;
mod volume_weighted_sr;
mod vortex;
mod vpin;
mod vpt;
@@ -494,6 +499,7 @@ 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;
@@ -530,6 +536,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;
@@ -719,6 +726,7 @@ 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};
@@ -748,6 +756,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;
@@ -908,6 +917,7 @@ 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;
@@ -1272,6 +1282,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"DemarkPivots",
"WilliamsFractals",
"ZigZag",
"CentralPivotRange",
"MurreyMathLines",
"AndrewsPitchfork",
"VolumeWeightedSr",
"PivotReversal",
],
),
(
@@ -1540,6 +1555,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, 462, "FAMILIES total drifted from indicator count");
assert_eq!(total, 467, "FAMILIES total drifted from indicator count");
}
}
@@ -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);
}
}
@@ -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);
}
}
+129
View File
@@ -81,6 +81,76 @@ 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 {
// Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
// which needs a single division instead of two and removes the separate
@@ -376,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]
+119
View File
@@ -86,6 +86,62 @@ impl Sma {
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 {
@@ -246,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,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);
}
}
+35 -34
View File
@@ -60,14 +60,15 @@ pub use indicators::{
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCci, AdaptiveCycle,
AdaptiveLaguerreFilter, AdaptiveRsi, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio,
Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi,
AnchoredVwap, 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,
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, CentralPivotRange,
CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
@@ -107,25 +108,25 @@ pub use indicators::{
MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa,
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop,
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr,
NrtrOutput, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta,
OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap,
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars,
PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, ProjectionBands,
ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands,
QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
RecoveryFactor, RectangleRange, 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,
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,
@@ -143,12 +144,12 @@ pub use indicators::{
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone,
VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, Vortex,
VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm,
WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, FAMILIES, T3,
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
@@ -165,4 +166,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 **462 indicators** across
- A per-indicator deep dive for every one of the **467 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.6.5",
"version": "0.6.6",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.6.5",
"wickra-darwin-x64": "0.6.5",
"wickra-linux-arm64-gnu": "0.6.5",
"wickra-linux-x64-gnu": "0.6.5",
"wickra-win32-arm64-msvc": "0.6.5",
"wickra-win32-x64-msvc": "0.6.5"
"wickra-darwin-arm64": "0.6.6",
"wickra-darwin-x64": "0.6.6",
"wickra-linux-arm64-gnu": "0.6.6",
"wickra-linux-x64-gnu": "0.6.6",
"wickra-win32-arm64-msvc": "0.6.6",
"wickra-win32-x64-msvc": "0.6.6"
}
},
"node_modules/wickra": {
+8 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
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, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, 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
@@ -433,4 +433,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);
});