Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57e26fb22f | |||
| 8431b1400c | |||
| ed01604a18 | |||
| 05fe7ffa90 | |||
| e97c3389fe | |||
| 4526278fa0 | |||
| 80850c81f7 | |||
| 707f29e8e4 | |||
| 389200f855 | |||
| 81406e7a1b | |||
| c78b84e186 |
@@ -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 **9–58× faster**;
|
||||
against the recompute-on-every-tick libraries it is **2 600–14 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
|
||||
```
|
||||
+52
-1
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.7] - 2026-06-08
|
||||
- **TD Camouflage** — a DeMark qualifier flagging hidden intrabar strength or weakness against the prior close (`TDCamouflage`).
|
||||
- **TD Clop** — a DeMark two-bar open/close engulfing reversal where the bar opens beyond and closes back across the prior body (`TDClop`).
|
||||
- **TD Clopwin** — the inside-body cousin of TD Clop, marking a compression bar whose direction hints at the next move (`TDClopwin`).
|
||||
- **TD Propulsion** — a DeMark continuation thrust that opens on the trend side and closes beyond the prior bar's extreme (`TDPropulsion`).
|
||||
- **TD Trap** — an inside ("trap") bar followed by a close beyond its range, triggering a directional breakout signal (`TDTrap`).
|
||||
- **TD D-Wave** — a streaming Elliott-style swing-wave counter labelling the market's 1–5 impulse / A–C correction sequence (`TDDWave`).
|
||||
- **TD Moving Averages** — the DeMark ST1 (fast) and ST2 (slow) median-price trend ribbon whose crossover frames the trend (`TDMovingAverage`).
|
||||
|
||||
## [0.6.6] - 2026-06-08
|
||||
- **Pivot Reversal** — a breakout signal when price closes through the most recently confirmed swing pivot (`PIVOT_REVERSAL`).
|
||||
- **Volume-Weighted Support/Resistance** — a band whose edges are the volume-weighted average of recent highs and lows (`VOLUME_WEIGHTED_SR`).
|
||||
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
|
||||
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
|
||||
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
|
||||
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
|
||||
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
|
||||
|
||||
## [0.6.5] - 2026-06-07
|
||||
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
|
||||
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
|
||||
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
|
||||
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
|
||||
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
|
||||
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
|
||||
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
|
||||
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
|
||||
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
|
||||
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
|
||||
|
||||
## [0.6.4] - 2026-06-07
|
||||
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
|
||||
- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`).
|
||||
- **Shannon Entropy** — Shannon entropy of a rolling value distribution over fixed bins (`SHANNONENT`).
|
||||
- **Rolling Min-Max Scaler** — Rolling min-max scaler mapping the latest value to 0..1 over a rolling window (`ROLLINGMINMAX`).
|
||||
- **Jarque-Bera** — Jarque-Bera normality test statistic over a rolling window (`JARQUEBERA`).
|
||||
|
||||
## [0.6.3] - 2026-06-07
|
||||
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
|
||||
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
|
||||
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
|
||||
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
|
||||
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
|
||||
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
|
||||
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
|
||||
|
||||
## [0.6.2] - 2026-06-07
|
||||
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
|
||||
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
|
||||
@@ -1316,7 +1362,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.2...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.7...HEAD
|
||||
[0.6.7]: https://github.com/wickra-lib/wickra/compare/v0.6.6...v0.6.7
|
||||
[0.6.6]: https://github.com/wickra-lib/wickra/compare/v0.6.5...v0.6.6
|
||||
[0.6.5]: https://github.com/wickra-lib/wickra/compare/v0.6.4...v0.6.5
|
||||
[0.6.4]: https://github.com/wickra-lib/wickra/compare/v0.6.3...v0.6.4
|
||||
[0.6.3]: https://github.com/wickra-lib/wickra/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
|
||||
|
||||
Generated
+8
-8
@@ -1944,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1955,7 +1955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
@@ -1967,7 +1967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1977,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2004,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -2014,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -2023,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
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.2" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.6.7" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=440" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=474" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](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 440 indicators; start at the
|
||||
every one of the 474 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
@@ -58,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.** 474 indicators across 24
|
||||
families — candlesticks, harmonic & chart patterns, market profile, market
|
||||
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
||||
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
||||
none of them stream.
|
||||
- **One Rust core, four first-class targets.** Native **Python · Node.js ·
|
||||
WebAssembly · Rust** — identical math, identical results, zero per-language
|
||||
reimplementation and zero GIL bottleneck.
|
||||
- **Correct by construction, not by hope.** Every `update` validates its input,
|
||||
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
||||
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
||||
for all 474 indicators**.
|
||||
- **Orders of magnitude faster where it counts.** In streaming Wickra is **9–58×**
|
||||
faster than the only other incremental peer and **thousands of times** faster
|
||||
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
|
||||
the losses are shown, not hidden.
|
||||
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra` —
|
||||
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
|
||||
macOS · Linux · Windows.
|
||||
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
|
||||
live Binance kline feed ship in the box.
|
||||
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
|
||||
and closed-source work.
|
||||
|
||||
Every other library forces one of those compromises. Wickra doesn't:
|
||||
|
||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** |
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **474** | **yes** |
|
||||
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
||||
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
||||
| yata | clean | partial | Rust only | ~35 | yes |
|
||||
@@ -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**: 440 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 **9–58× 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 | **★ 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 | **★ 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 | **★ Wickra** | finta | TA-Lib | tulipy |
|
||||
|------------------|------------------:|---------------------|--------|--------|
|
||||
| SMA(20) | **59.6 ★** | 354.2 (5.9× slower) | ⧗ | ⧗ |
|
||||
| EMA(20) | **88.4 ★** | 309.3 (3.5× slower) | ⧗ | ⧗ |
|
||||
| RSI(14) | **77.3 ★** | 1 283 (16.6× slower)| ⧗ | ⧗ |
|
||||
| MACD(12, 26, 9) | **116.4 ★** | 529.5 (4.6× slower) | ⧗ | ⧗ |
|
||||
| Bollinger(20, 2) | **146.0 ★** | 1 246 (8.5× slower) | ⧗ | ⧗ |
|
||||
| ATR(14) | **135.8 ★** | 3 812 (28× slower) | ⧗ | ⧗ |
|
||||
|
||||
> ⧗ = published by the CI Linux job. TA-Lib and tulipy ship C extensions that
|
||||
> don't build cleanly on every desktop, so their canonical numbers come from the
|
||||
> `cross-library-bench` workflow rather than this local table. pandas-ta needs
|
||||
> Python ≥ 3.12 and isn't in the 3.11 CI matrix. The script auto-detects
|
||||
> whichever peers are installed in your environment.
|
||||
|
||||
### 3. Python — streaming (per-tick latency)
|
||||
|
||||
Seed 5 000 bars, then feed ticks one at a time. talipp is the only Python peer
|
||||
with a true incremental API; batch-only libraries like TA-Lib must recompute the
|
||||
entire history on every tick — Wickra updates in O(1).
|
||||
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|------------------|------------------------------:|-------------------------|
|
||||
| SMA(20) | **0.067 µs ★** | 0.63 µs (9.4× slower) |
|
||||
| EMA(20) | **0.051 µs ★** | 0.63 µs (12.2× slower) |
|
||||
| RSI(14) | **0.053 µs ★** | 1.00 µs (19.1× slower) |
|
||||
| MACD(12, 26, 9) | **0.071 µs ★** | 3.64 µs (51.5× slower) |
|
||||
| Bollinger(20, 2) | **0.085 µs ★** | 4.87 µs (57.2× slower) |
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
440 streaming-first indicators across twenty-four families. Every one passes the
|
||||
474 streaming-first indicators across twenty-four families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
@@ -202,11 +142,11 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level, TD Camouflage, TD Clop, TD Clopwin, TD Propulsion, TD Trap, TD D-Wave, TD Moving Averages |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
|
||||
@@ -297,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 440 indicators
|
||||
│ ├── wickra-core/ core engine + all 474 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
|
||||
|
||||
@@ -28,6 +28,19 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
|
||||
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
|
||||
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
|
||||
UNIVERSALOSC: () => new wickra.UNIVERSALOSC(20),
|
||||
ADAPTIVERSI: () => new wickra.ADAPTIVERSI(14),
|
||||
CTI: () => new wickra.CTI(20),
|
||||
TRENDFLEX: () => new wickra.TRENDFLEX(20),
|
||||
REFLEX: () => new wickra.REFLEX(20),
|
||||
HIGHPASS: () => new wickra.HIGHPASS(48),
|
||||
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
|
||||
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
|
||||
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
|
||||
JARQUEBERA: () => new wickra.JARQUEBERA(20),
|
||||
BipowerVariation: () => new wickra.BipowerVariation(20),
|
||||
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
|
||||
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
|
||||
@@ -357,6 +370,20 @@ const candleScalar = {
|
||||
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDCamouflage: { make: () => new wickra.TDCamouflage(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClop: { make: () => new wickra.TDClop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClopwin: { make: () => new wickra.TDClopwin(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDPropulsion: { make: () => new wickra.TDPropulsion(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDTrap: { make: () => new wickra.TDTrap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDDWave: { make: () => new wickra.TDDWave(2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -453,6 +480,12 @@ const multi = {
|
||||
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
TDMovingAverage: { make: () => new wickra.TDMovingAverage(5, 13), fields: ['st1', 'st2'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -622,6 +655,7 @@ const pairFactories = {
|
||||
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
|
||||
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
|
||||
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
|
||||
KendallTau: () => new wickra.KendallTau(20),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
|
||||
Vendored
+348
@@ -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
|
||||
@@ -317,6 +342,10 @@ export interface TdSequentialValue {
|
||||
countdown: number
|
||||
direction: number
|
||||
}
|
||||
export interface TdMovingAverageValue {
|
||||
st1: number
|
||||
st2: number
|
||||
}
|
||||
/** TD Lines output pair: latest TDST resistance / support (NaN if unset). */
|
||||
export interface TdLinesValue {
|
||||
resistance: number
|
||||
@@ -489,6 +518,11 @@ export interface FibTimeZonesValue {
|
||||
onZone: number
|
||||
barsToNext: number
|
||||
}
|
||||
export interface VolumeWeightedMacdValue {
|
||||
macd: number
|
||||
signal: number
|
||||
histogram: number
|
||||
}
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
@@ -1047,6 +1081,123 @@ export declare class BipowerVariation {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type JarqueBeraNode = JARQUEBERA
|
||||
export declare class JARQUEBERA {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RollingMinMaxScalerNode = ROLLINGMINMAX
|
||||
export declare class ROLLINGMINMAX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HighpassFilterNode = HIGHPASS
|
||||
export declare class HIGHPASS {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ReflexNode = REFLEX
|
||||
export declare class REFLEX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TrendflexNode = TRENDFLEX
|
||||
export declare class TRENDFLEX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CorrelationTrendIndicatorNode = CTI
|
||||
export declare class CTI {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdaptiveRsiNode = ADAPTIVERSI
|
||||
export declare class ADAPTIVERSI {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type UniversalOscillatorNode = UNIVERSALOSC
|
||||
export declare class UNIVERSALOSC {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BandpassFilterNode = BANDPASS
|
||||
export declare class BANDPASS {
|
||||
constructor(period: number, bandwidth: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EvenBetterSinewaveNode = EVENBETTERSINE
|
||||
export declare class EVENBETTERSINE {
|
||||
constructor(hpPeriod: number, ssfLength: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AutocorrelationPeriodogramNode = AUTOCORRPGRAM
|
||||
export declare class AUTOCORRPGRAM {
|
||||
constructor(minPeriod: number, maxPeriod: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ShannonEntropyNode = SHANNONENT
|
||||
export declare class SHANNONENT {
|
||||
constructor(period: number, bins: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SampleEntropyNode = SAMPLEENT
|
||||
export declare class SAMPLEENT {
|
||||
constructor(period: number, m: number, rFactor: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EwmaVolatilityNode = EwmaVolatility
|
||||
export declare class EwmaVolatility {
|
||||
constructor(lambda: number)
|
||||
@@ -1258,6 +1409,19 @@ export declare class DistanceSsd {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KendallTauNode = KendallTau
|
||||
export declare class KendallTau {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BetaNeutralSpreadNode = BetaNeutralSpread
|
||||
export declare class BetaNeutralSpread {
|
||||
constructor(period: number)
|
||||
@@ -1697,6 +1861,15 @@ export declare class TimeBasedStop {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdaptiveCciNode = ADAPTIVECCI
|
||||
export declare class ADAPTIVECCI {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochNode = Stochastic
|
||||
export declare class Stochastic {
|
||||
constructor(kPeriod: number, dPeriod: number)
|
||||
@@ -2786,6 +2959,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)
|
||||
@@ -2945,6 +3163,24 @@ export declare class TDCombo {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdDWaveNode = TDDWave
|
||||
export declare class TDDWave {
|
||||
constructor(strength: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdMovingAverageNode = TDMovingAverage
|
||||
export declare class TDMovingAverage {
|
||||
constructor(periodSt1: number, periodSt2: number)
|
||||
update(high: number, low: number): TdMovingAverageValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdCountdownNode = TDCountdown
|
||||
export declare class TDCountdown {
|
||||
constructor(setupLookback: number, setupTarget: number, countdownLookback: number, countdownTarget: number)
|
||||
@@ -3858,6 +4094,51 @@ export declare class ThreeDrives {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdCamouflageNode = TDCamouflage
|
||||
export declare class TDCamouflage {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdClopNode = TDClop
|
||||
export declare class TDClop {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdClopwinNode = TDClopwin
|
||||
export declare class TDClopwin {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdPropulsionNode = TDPropulsion
|
||||
export declare class TDPropulsion {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdTrapNode = TDTrap
|
||||
export declare class TDTrap {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
|
||||
export declare class OrderBookImbalanceTop1 {
|
||||
constructor()
|
||||
@@ -4640,3 +4921,70 @@ export declare class FibTimeZones {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeRsiNode = VolumeRsi
|
||||
export declare class VolumeRsi {
|
||||
constructor(period: number)
|
||||
update(close: number, volume: number): number | null
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type WadNode = Wad
|
||||
export declare class Wad {
|
||||
constructor()
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TwiggsMoneyFlowNode = TwiggsMoneyFlow
|
||||
export declare class TwiggsMoneyFlow {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TradeVolumeIndexNode = TradeVolumeIndex
|
||||
export declare class TradeVolumeIndex {
|
||||
constructor(minTick: number)
|
||||
update(close: number, volume: number): number | null
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type IntradayIntensityNode = IntradayIntensity
|
||||
export declare class IntradayIntensity {
|
||||
constructor()
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BetterVolumeNode = BetterVolume
|
||||
export declare class BetterVolume {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeWeightedMacdNode = VolumeWeightedMacd
|
||||
export declare class VolumeWeightedMacd {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
update(close: number, volume: number): VolumeWeightedMacdValue | null
|
||||
/**
|
||||
* Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
|
||||
+35
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.6.2",
|
||||
"wickra-darwin-x64": "0.6.2",
|
||||
"wickra-linux-arm64-gnu": "0.6.2",
|
||||
"wickra-linux-x64-gnu": "0.6.2",
|
||||
"wickra-win32-arm64-msvc": "0.6.2",
|
||||
"wickra-win32-x64-msvc": "0.6.2"
|
||||
"wickra-darwin-arm64": "0.6.7",
|
||||
"wickra-darwin-x64": "0.6.7",
|
||||
"wickra-linux-arm64-gnu": "0.6.7",
|
||||
"wickra-linux-x64-gnu": "0.6.7",
|
||||
"wickra-win32-arm64-msvc": "0.6.7",
|
||||
"wickra-win32-x64-msvc": "0.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.2.tgz",
|
||||
"version": "0.6.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.7.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.2.tgz",
|
||||
"version": "0.6.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.7.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.2.tgz",
|
||||
"version": "0.6.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.7.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.2.tgz",
|
||||
"version": "0.6.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.7.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.2.tgz",
|
||||
"version": "0.6.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.7.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.2.tgz",
|
||||
"version": "0.6.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.7.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
@@ -47,12 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.6.2",
|
||||
"wickra-linux-arm64-gnu": "0.6.2",
|
||||
"wickra-darwin-x64": "0.6.2",
|
||||
"wickra-darwin-arm64": "0.6.2",
|
||||
"wickra-win32-x64-msvc": "0.6.2",
|
||||
"wickra-win32-arm64-msvc": "0.6.2"
|
||||
"wickra-linux-x64-gnu": "0.6.7",
|
||||
"wickra-linux-arm64-gnu": "0.6.7",
|
||||
"wickra-darwin-x64": "0.6.7",
|
||||
"wickra-darwin-arm64": "0.6.7",
|
||||
"wickra-win32-x64-msvc": "0.6.7",
|
||||
"wickra-win32-arm64-msvc": "0.6.7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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__":
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -25,6 +25,20 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
AUTOCORRPGRAM,
|
||||
EVENBETTERSINE,
|
||||
BANDPASS,
|
||||
ADAPTIVECCI,
|
||||
UNIVERSALOSC,
|
||||
ADAPTIVERSI,
|
||||
CTI,
|
||||
TRENDFLEX,
|
||||
REFLEX,
|
||||
HIGHPASS,
|
||||
SAMPLEENT,
|
||||
SHANNONENT,
|
||||
ROLLINGMINMAX,
|
||||
JARQUEBERA,
|
||||
TimeBasedStop,
|
||||
ProjectionOscillator,
|
||||
VolatilityCone,
|
||||
@@ -194,6 +208,13 @@ from ._wickra import (
|
||||
RogersSatchellVolatility,
|
||||
YangZhangVolatility,
|
||||
# Volume
|
||||
VolumeWeightedMacd,
|
||||
BetterVolume,
|
||||
IntradayIntensity,
|
||||
TradeVolumeIndex,
|
||||
TwiggsMoneyFlow,
|
||||
Wad,
|
||||
VolumeRsi,
|
||||
OBV,
|
||||
VWAP,
|
||||
RollingVWAP,
|
||||
@@ -214,6 +235,7 @@ from ._wickra import (
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
KendallTau,
|
||||
SpreadBollingerBands,
|
||||
KalmanHedgeRatio,
|
||||
GrangerCausality,
|
||||
@@ -287,6 +309,11 @@ from ._wickra import (
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
PivotReversal,
|
||||
VolumeWeightedSr,
|
||||
AndrewsPitchfork,
|
||||
MurreyMathLines,
|
||||
CentralPivotRange,
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
@@ -295,6 +322,13 @@ from ._wickra import (
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
# DeMark
|
||||
TDMovingAverage,
|
||||
TDDWave,
|
||||
TDTrap,
|
||||
TDPropulsion,
|
||||
TDClopwin,
|
||||
TDClop,
|
||||
TDCamouflage,
|
||||
TDSetup,
|
||||
TDSequential,
|
||||
TDDeMarker,
|
||||
@@ -494,6 +528,20 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AUTOCORRPGRAM",
|
||||
"EVENBETTERSINE",
|
||||
"BANDPASS",
|
||||
"ADAPTIVECCI",
|
||||
"UNIVERSALOSC",
|
||||
"ADAPTIVERSI",
|
||||
"CTI",
|
||||
"TRENDFLEX",
|
||||
"REFLEX",
|
||||
"HIGHPASS",
|
||||
"SAMPLEENT",
|
||||
"SHANNONENT",
|
||||
"ROLLINGMINMAX",
|
||||
"JARQUEBERA",
|
||||
"TimeBasedStop",
|
||||
"ProjectionOscillator",
|
||||
"VolatilityCone",
|
||||
@@ -664,6 +712,13 @@ __all__ = [
|
||||
"RogersSatchellVolatility",
|
||||
"YangZhangVolatility",
|
||||
# Volume
|
||||
"VolumeWeightedMacd",
|
||||
"BetterVolume",
|
||||
"IntradayIntensity",
|
||||
"TradeVolumeIndex",
|
||||
"TwiggsMoneyFlow",
|
||||
"Wad",
|
||||
"VolumeRsi",
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"RollingVWAP",
|
||||
@@ -684,6 +739,7 @@ __all__ = [
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"KendallTau",
|
||||
"SpreadBollingerBands",
|
||||
"KalmanHedgeRatio",
|
||||
"GrangerCausality",
|
||||
@@ -757,6 +813,11 @@ __all__ = [
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"PivotReversal",
|
||||
"VolumeWeightedSr",
|
||||
"AndrewsPitchfork",
|
||||
"MurreyMathLines",
|
||||
"CentralPivotRange",
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
@@ -765,6 +826,13 @@ __all__ = [
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
# DeMark
|
||||
"TDMovingAverage",
|
||||
"TDDWave",
|
||||
"TDTrap",
|
||||
"TDPropulsion",
|
||||
"TDClopwin",
|
||||
"TDClop",
|
||||
"TDCamouflage",
|
||||
"TDSetup",
|
||||
"TDSequential",
|
||||
"TDDeMarker",
|
||||
|
||||
+1926
-150
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,19 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.AUTOCORRPGRAM, (10, 48)),
|
||||
(ta.EVENBETTERSINE, (40, 10)),
|
||||
(ta.BANDPASS, (20, 0.3)),
|
||||
(ta.UNIVERSALOSC, (20,)),
|
||||
(ta.ADAPTIVERSI, (14,)),
|
||||
(ta.CTI, (20,)),
|
||||
(ta.TRENDFLEX, (20,)),
|
||||
(ta.REFLEX, (20,)),
|
||||
(ta.HIGHPASS, (48,)),
|
||||
(ta.SAMPLEENT, (20, 2, 0.2)),
|
||||
(ta.SHANNONENT, (20, 8)),
|
||||
(ta.ROLLINGMINMAX, (20,)),
|
||||
(ta.JARQUEBERA, (20,)),
|
||||
(ta.BipowerVariation, (20,)),
|
||||
(ta.VolatilityOfVolatility, (20, 20)),
|
||||
(ta.Garch11, (0.000002, 0.1, 0.88)),
|
||||
@@ -204,6 +217,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
||||
# --- Two-series (asset, benchmark) indicators -----------------------------
|
||||
|
||||
PAIR = [
|
||||
(ta.KendallTau, (20,)),
|
||||
(ta.SpreadAr1Coefficient, (40,)),
|
||||
(ta.GrangerCausality, (60, 1)),
|
||||
(ta.VarianceRatio, (60, 2)),
|
||||
@@ -368,6 +382,59 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"TDDWave": (
|
||||
lambda: ta.TDDWave(2),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TDTrap": (
|
||||
lambda: ta.TDTrap(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDPropulsion": (
|
||||
lambda: ta.TDPropulsion(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDClopwin": (
|
||||
lambda: ta.TDClopwin(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDClop": (
|
||||
lambda: ta.TDClop(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDCamouflage": (
|
||||
lambda: ta.TDCamouflage(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"PivotReversal": (
|
||||
lambda: ta.PivotReversal(1, 1),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"ADAPTIVECCI": (lambda: ta.ADAPTIVECCI(20), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"BetterVolume": (
|
||||
lambda: ta.BetterVolume(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"IntradayIntensity": (
|
||||
lambda: ta.IntradayIntensity(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"TradeVolumeIndex": (
|
||||
lambda: ta.TradeVolumeIndex(0.25),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"TwiggsMoneyFlow": (
|
||||
lambda: ta.TwiggsMoneyFlow(21),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"Wad": (
|
||||
lambda: ta.Wad(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"VolumeRsi": (
|
||||
lambda: ta.VolumeRsi(14),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
@@ -909,6 +976,36 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"TDMovingAverage": (
|
||||
lambda: ta.TDMovingAverage(5, 13),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
"VolumeWeightedSr": (
|
||||
lambda: ta.VolumeWeightedSr(3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
2,
|
||||
),
|
||||
"AndrewsPitchfork": (
|
||||
lambda: ta.AndrewsPitchfork(2),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
3,
|
||||
),
|
||||
"MurreyMathLines": (
|
||||
lambda: ta.MurreyMathLines(4),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
9,
|
||||
),
|
||||
"CentralPivotRange": (
|
||||
lambda: ta.CentralPivotRange(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
3,
|
||||
),
|
||||
"VolumeWeightedMacd": (
|
||||
lambda: ta.VolumeWeightedMacd(12, 26, 9),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
3,
|
||||
),
|
||||
"ModifiedMaStop": (
|
||||
lambda: ta.ModifiedMaStop(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
@@ -1580,7 +1677,7 @@ def test_kvo_constant_series_is_zero():
|
||||
assert v == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_williams_ad_reference():
|
||||
def test_wad_reference():
|
||||
# bar 0 seeds prev_close = 10.
|
||||
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
|
||||
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
|
||||
@@ -3040,6 +3137,104 @@ def test_modified_ma_stop_reference():
|
||||
assert t.update(c) is None
|
||||
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
|
||||
|
||||
|
||||
def test_volume_rsi_reference():
|
||||
t = ta.VolumeRsi(14)
|
||||
|
||||
|
||||
def test_twiggs_money_flow_reference():
|
||||
t = ta.TwiggsMoneyFlow(21)
|
||||
|
||||
|
||||
def test_trade_volume_index_reference():
|
||||
t = ta.TradeVolumeIndex(0.25)
|
||||
|
||||
|
||||
def test_intraday_intensity_reference():
|
||||
t = ta.IntradayIntensity()
|
||||
|
||||
|
||||
def test_better_volume_reference():
|
||||
t = ta.BetterVolume(14)
|
||||
|
||||
|
||||
def test_volume_weighted_macd_reference():
|
||||
t = ta.VolumeWeightedMacd(12, 26, 9)
|
||||
|
||||
|
||||
def test_kendall_tau_reference():
|
||||
t = ta.KendallTau(20)
|
||||
|
||||
|
||||
def test_central_pivot_range_reference():
|
||||
t = ta.CentralPivotRange()
|
||||
assert t.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) == pytest.approx((101.66666666666667, 103.33333333333334, 100.0))
|
||||
|
||||
|
||||
def test_murrey_math_lines_reference():
|
||||
t = ta.MurreyMathLines(4)
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 0)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 1)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 2)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 3)) == pytest.approx((180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, 100.0))
|
||||
|
||||
|
||||
def test_andrews_pitchfork_reference():
|
||||
t = ta.AndrewsPitchfork(2)
|
||||
# Warmup: no pitchfork until three alternating swing pivots are confirmed.
|
||||
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
|
||||
|
||||
|
||||
def test_volume_weighted_sr_reference():
|
||||
t = ta.VolumeWeightedSr(3)
|
||||
assert t.update((100.0, 102.0, 98.0, 100.0, 1.0, 0)) is None
|
||||
assert t.update((100.0, 104.0, 96.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((100.0, 106.0, 94.0, 100.0, 1.0, 2)) == pytest.approx((96.0, 104.0))
|
||||
|
||||
|
||||
def test_pivot_reversal_reference():
|
||||
t = ta.PivotReversal(1, 1)
|
||||
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 0)) is None
|
||||
assert t.update((11.5, 12.0, 11.0, 11.5, 1.0, 1)) is None
|
||||
# Pivot high = 12 confirmed; close 9.5 has not crossed it.
|
||||
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 11.0, 9.0, 9.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
# Close 13 > pivot high 12 with prev close 9 below it -> bullish reversal.
|
||||
assert t.update((13.0, 14.0, 12.5, 13.0, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
|
||||
def test_td_camouflage_reference():
|
||||
t = ta.TDCamouflage()
|
||||
assert t.update((10.0, 11.0, 8.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 10.0, 7.0, 9.5, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_clop_reference():
|
||||
t = ta.TDClop()
|
||||
assert t.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 13.0, 8.0, 12.0, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_clopwin_reference():
|
||||
t = ta.TDClopwin()
|
||||
assert t.update((10.0, 15.0, 9.0, 14.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((11.0, 14.0, 10.0, 13.0, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_propulsion_reference():
|
||||
t = ta.TDPropulsion()
|
||||
assert t.update((9.5, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((10.5, 12.0, 10.0, 11.5, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_trap_reference():
|
||||
t = ta.TDTrap()
|
||||
assert t.update((100.0, 110.0, 90.0, 100.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((101.5, 108.0, 95.0, 102.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((106.0, 112.0, 100.0, 109.0, 1.0, 2)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -557,6 +557,7 @@ wasm_pair_indicator!(
|
||||
wasm_pair_indicator!(WasmOuHalfLife, "OuHalfLife", wc::OuHalfLife);
|
||||
wasm_pair_indicator!(WasmSpreadHurst, "SpreadHurst", wc::SpreadHurst);
|
||||
wasm_pair_indicator!(WasmDistanceSsd, "DistanceSsd", wc::DistanceSsd);
|
||||
wasm_pair_indicator!(WasmKendallTau, "KendallTau", wc::KendallTau);
|
||||
wasm_pair_indicator!(
|
||||
WasmBetaNeutralSpread,
|
||||
"BetaNeutralSpread",
|
||||
@@ -2590,6 +2591,44 @@ impl WasmTimeBasedStop {
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = ADAPTIVECCI)]
|
||||
pub struct WasmAdaptiveCci {
|
||||
inner: wc::AdaptiveCci,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ADAPTIVECCI)]
|
||||
impl WasmAdaptiveCci {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmAdaptiveCci, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::AdaptiveCci::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Stochastic)]
|
||||
pub struct WasmStoch {
|
||||
inner: wc::Stochastic,
|
||||
@@ -6402,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)]
|
||||
@@ -7476,6 +7817,109 @@ impl WasmTdCombo {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- TD D-Wave ----------
|
||||
|
||||
#[wasm_bindgen(js_name = TDDWave)]
|
||||
pub struct WasmTdDWave {
|
||||
inner: wc::TdDWave,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TDDWave)]
|
||||
impl WasmTdDWave {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(strength: usize) -> Result<WasmTdDWave, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TdDWave::new(strength).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- TD Moving Averages ----------
|
||||
|
||||
#[wasm_bindgen(js_name = TDMovingAverage)]
|
||||
pub struct WasmTdMovingAverage {
|
||||
inner: wc::TdMovingAverage,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TDMovingAverage)]
|
||||
impl WasmTdMovingAverage {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period_st1: usize, period_st2: usize) -> Result<WasmTdMovingAverage, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TdMovingAverage::new(period_st1, period_st2).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, &"st1".into(), &o.st1.into()).ok();
|
||||
Reflect::set(&obj, &"st2".into(), &o.st2.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, low 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], 0.0)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * 2] = o.st1;
|
||||
out[i * 2 + 1] = o.st2;
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- TD Countdown ----------
|
||||
|
||||
#[wasm_bindgen(js_name = TDCountdown)]
|
||||
@@ -8601,6 +9045,11 @@ wasm_candle_pattern!(WasmCrab, wc::Crab, Crab);
|
||||
wasm_candle_pattern!(WasmShark, wc::Shark, Shark);
|
||||
wasm_candle_pattern!(WasmCypher, wc::Cypher, Cypher);
|
||||
wasm_candle_pattern!(WasmThreeDrives, wc::ThreeDrives, ThreeDrives);
|
||||
wasm_candle_pattern!(WasmTdCamouflage, wc::TdCamouflage, TDCamouflage);
|
||||
wasm_candle_pattern!(WasmTdClop, wc::TdClop, TDClop);
|
||||
wasm_candle_pattern!(WasmTdClopwin, wc::TdClopwin, TDClopwin);
|
||||
wasm_candle_pattern!(WasmTdPropulsion, wc::TdPropulsion, TDPropulsion);
|
||||
wasm_candle_pattern!(WasmTdTrap, wc::TdTrap, TDTrap);
|
||||
|
||||
// ============================== Microstructure: Order Book ==============================
|
||||
//
|
||||
@@ -11256,6 +11705,19 @@ wasm_scalar_indicator!(WasmBipowerVariation, "BipowerVariation", wc::BipowerVari
|
||||
wasm_scalar_indicator!(WasmEwmaVolatility, "EwmaVolatility", wc::EwmaVolatility, lambda: f64);
|
||||
wasm_scalar_indicator!(WasmGarch11, "Garch11", wc::Garch11, omega: f64, alpha: f64, beta: f64);
|
||||
wasm_scalar_indicator!(WasmVolatilityOfVolatility, "VolatilityOfVolatility", wc::VolatilityOfVolatility, vol_window: usize, vov_window: usize);
|
||||
wasm_scalar_indicator!(WasmJarqueBera, "JARQUEBERA", wc::JarqueBera, period: usize);
|
||||
wasm_scalar_indicator!(WasmRollingMinMaxScaler, "ROLLINGMINMAX", wc::RollingMinMaxScaler, period: usize);
|
||||
wasm_scalar_indicator!(WasmShannonEntropy, "SHANNONENT", wc::ShannonEntropy, period: usize, bins: usize);
|
||||
wasm_scalar_indicator!(WasmSampleEntropy, "SAMPLEENT", wc::SampleEntropy, period: usize, m: usize, r_factor: f64);
|
||||
wasm_scalar_indicator!(WasmHighpassFilter, "HIGHPASS", wc::HighpassFilter, period: usize);
|
||||
wasm_scalar_indicator!(WasmReflex, "REFLEX", wc::Reflex, period: usize);
|
||||
wasm_scalar_indicator!(WasmTrendflex, "TRENDFLEX", wc::Trendflex, period: usize);
|
||||
wasm_scalar_indicator!(WasmCorrelationTrendIndicator, "CTI", wc::CorrelationTrendIndicator, period: usize);
|
||||
wasm_scalar_indicator!(WasmAdaptiveRsi, "ADAPTIVERSI", wc::AdaptiveRsi, period: usize);
|
||||
wasm_scalar_indicator!(WasmUniversalOscillator, "UNIVERSALOSC", wc::UniversalOscillator, period: usize);
|
||||
wasm_scalar_indicator!(WasmBandpassFilter, "BANDPASS", wc::BandpassFilter, period: usize, bandwidth: f64);
|
||||
wasm_scalar_indicator!(WasmEvenBetterSinewave, "EVENBETTERSINE", wc::EvenBetterSinewave, hp_period: usize, ssf_length: usize);
|
||||
wasm_scalar_indicator!(WasmAutocorrelationPeriodogram, "AUTOCORRPGRAM", wc::AutocorrelationPeriodogram, min_period: usize, max_period: usize);
|
||||
|
||||
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
|
||||
|
||||
@@ -12712,3 +13174,379 @@ impl WasmFibTimeZones {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Volume RSI ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = VolumeRsi)]
|
||||
pub struct WasmVolumeRsi {
|
||||
inner: wc::VolumeRsi,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = VolumeRsi)]
|
||||
impl WasmVolumeRsi {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmVolumeRsi, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::VolumeRsi::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(close, close, close, volume)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if close.len() != volume.len() {
|
||||
return Err(JsError::new("close and volume must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(close[i], close[i], close[i], volume[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Williams A/D ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = Wad)]
|
||||
pub struct WasmWad {
|
||||
inner: wc::Wad,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Wad)]
|
||||
impl WasmWad {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmWad {
|
||||
Self {
|
||||
inner: wc::Wad::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Twiggs Money Flow ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = TwiggsMoneyFlow)]
|
||||
pub struct WasmTwiggsMoneyFlow {
|
||||
inner: wc::TwiggsMoneyFlow,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TwiggsMoneyFlow)]
|
||||
impl WasmTwiggsMoneyFlow {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmTwiggsMoneyFlow, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TwiggsMoneyFlow::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
volume: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, volume)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
|
||||
return Err(JsError::new(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], volume[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Trade Volume Index ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = TradeVolumeIndex)]
|
||||
pub struct WasmTradeVolumeIndex {
|
||||
inner: wc::TradeVolumeIndex,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TradeVolumeIndex)]
|
||||
impl WasmTradeVolumeIndex {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(min_tick: f64) -> Result<WasmTradeVolumeIndex, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(close, close, close, volume)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if close.len() != volume.len() {
|
||||
return Err(JsError::new("close and volume must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(close[i], close[i], close[i], volume[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Intraday Intensity ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = IntradayIntensity)]
|
||||
pub struct WasmIntradayIntensity {
|
||||
inner: wc::IntradayIntensity,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = IntradayIntensity)]
|
||||
impl WasmIntradayIntensity {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmIntradayIntensity {
|
||||
Self {
|
||||
inner: wc::IntradayIntensity::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
volume: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, volume)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
|
||||
return Err(JsError::new(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], volume[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Better Volume ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = BetterVolume)]
|
||||
pub struct WasmBetterVolume {
|
||||
inner: wc::BetterVolume,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = BetterVolume)]
|
||||
impl WasmBetterVolume {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmBetterVolume, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::BetterVolume::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
volume: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, volume)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
|
||||
return Err(JsError::new(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], volume[i])?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = 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 MACD ==============================
|
||||
|
||||
#[wasm_bindgen(js_name = VolumeWeightedMacd)]
|
||||
pub struct WasmVolumeWeightedMacd {
|
||||
inner: wc::VolumeWeightedMacd,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = VolumeWeightedMacd)]
|
||||
impl WasmVolumeWeightedMacd {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<WasmVolumeWeightedMacd, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `{ macd, signal, histogram }` once warm, else `null`.
|
||||
pub fn update(&mut self, close: f64, volume: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(close, close, close, volume)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"macd".into(), &o.macd.into()).ok();
|
||||
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
|
||||
Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
/// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
|
||||
/// Warmup is NaN.
|
||||
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if close.len() != volume.len() {
|
||||
return Err(JsError::new("close and volume must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; close.len() * 3];
|
||||
for i in 0..close.len() {
|
||||
let c = make_candle(close[i], close[i], close[i], volume[i])?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 3] = o.macd;
|
||||
out[i * 3 + 1] = o.signal;
|
||||
out[i * 3 + 2] = o.histogram;
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmWad {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmIntradayIntensity {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,245 @@
|
||||
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
|
||||
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
|
||||
/// plain SMA, so it leads in trends and stays calm in chop.
|
||||
///
|
||||
/// ```text
|
||||
/// TP = (high + low + close) / 3
|
||||
/// ER = |TP_t − TP_oldest| / Σ |ΔTP| over the window (0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )²
|
||||
/// mean += sc·(TP_t − mean) (adaptive centre, seeded with SMA)
|
||||
/// MD = mean(|TP_i − mean|) over the window (mean deviation)
|
||||
/// CCI = (TP_t − mean) / (0.015 · MD)
|
||||
/// ```
|
||||
///
|
||||
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
|
||||
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
|
||||
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
|
||||
/// lets the centre line accelerate toward price in a clean trend (so the CCI
|
||||
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
|
||||
/// The `0.015` scaling keeps Lambert's convention that roughly 70–80% of readings
|
||||
/// fall in `[−100, +100]`.
|
||||
///
|
||||
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
|
||||
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
|
||||
///
|
||||
/// let mut indicator = AdaptiveCci::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveCci {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
mean: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCci {
|
||||
/// Construct an adaptive CCI with the given `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
|
||||
/// path of at least one step).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "adaptive CCI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
mean: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tp = candle.typical_price();
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(tp);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
|
||||
// Efficiency ratio over the window.
|
||||
let oldest = self.window[0];
|
||||
let direction = (tp - oldest).abs();
|
||||
let mut path = 0.0;
|
||||
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
|
||||
path += (pair[1] - pair[0]).abs();
|
||||
}
|
||||
let er = if path > 0.0 {
|
||||
(direction / path).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
|
||||
let mean = match self.mean {
|
||||
None => self.window.iter().sum::<f64>() / n,
|
||||
Some(prev) => prev + sc * (tp - prev),
|
||||
};
|
||||
self.mean = Some(mean);
|
||||
|
||||
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
|
||||
let cci = if md > 0.0 {
|
||||
(tp - mean) / (0.015 * md)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(cci);
|
||||
Some(cci)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.mean = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCci"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(tp: f64) -> Candle {
|
||||
// open=high=low=close=tp -> typical price == tp.
|
||||
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
AdaptiveCci::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = AdaptiveCci::new(20).unwrap();
|
||||
assert_eq!(c.period(), 20);
|
||||
assert_eq!(c.warmup_period(), 20);
|
||||
assert_eq!(c.name(), "AdaptiveCci");
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut c = AdaptiveCci::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let out = c.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
|
||||
for v in c.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
c.batch(&candles);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
assert_eq!(c.update(candle(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
|
||||
let mut b = AdaptiveCci::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
|
||||
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
|
||||
/// oscillator reacts fast in a clean move and smooths through chop.
|
||||
///
|
||||
/// ```text
|
||||
/// ER = |price_t − price_{t−period}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )² (KAMA smoothing constant)
|
||||
/// avg_gain += sc·(gain − avg_gain), avg_loss += sc·(loss − avg_loss)
|
||||
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
|
||||
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
|
||||
/// efficiency ratio (`directional move / total path`) to set the smoothing each
|
||||
/// bar — near `1` (a clean trend) the averages track gains and losses almost
|
||||
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
|
||||
/// is an RSI that is responsive when it should be and quiet when it should be. It
|
||||
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
|
||||
/// sets the lookback from the measured dominant cycle.
|
||||
///
|
||||
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
|
||||
/// first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveRsi};
|
||||
///
|
||||
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveRsi {
|
||||
period: usize,
|
||||
prices: VecDeque<f64>,
|
||||
abs_changes: VecDeque<f64>,
|
||||
abs_sum: f64,
|
||||
prev: Option<f64>,
|
||||
seed_gain: f64,
|
||||
seed_loss: f64,
|
||||
seed_count: usize,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveRsi {
|
||||
/// Construct an adaptive RSI with the given efficiency-ratio `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prices: VecDeque::with_capacity(period + 1),
|
||||
abs_changes: VecDeque::with_capacity(period),
|
||||
abs_sum: 0.0,
|
||||
prev: None,
|
||||
seed_gain: 0.0,
|
||||
seed_loss: 0.0,
|
||||
seed_count: 0,
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured efficiency-ratio period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
|
||||
fn efficiency_ratio(&self, price: f64) -> f64 {
|
||||
let oldest = *self.prices.front().expect("window non-empty");
|
||||
let direction = (price - oldest).abs();
|
||||
if self.abs_sum == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(direction / self.abs_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
self.prices.push_back(price);
|
||||
return None;
|
||||
};
|
||||
let change = price - prev;
|
||||
self.prev = Some(price);
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
// Maintain the price window (period + 1) and the |Δ| window (period).
|
||||
self.prices.push_back(price);
|
||||
if self.prices.len() > self.period + 1 {
|
||||
self.prices.pop_front();
|
||||
}
|
||||
if self.abs_changes.len() == self.period {
|
||||
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
|
||||
}
|
||||
self.abs_changes.push_back(change.abs());
|
||||
self.abs_sum += change.abs();
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let er = self.efficiency_ratio(price);
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
let new_ag = ag + sc * (gain - ag);
|
||||
let new_al = al + sc * (loss - al);
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
let v = Self::rsi_from_avgs(new_ag, new_al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gain += gain;
|
||||
self.seed_loss += loss;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let ag = self.seed_gain / self.period as f64;
|
||||
let al = self.seed_loss / self.period as f64;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rsi_from_avgs(ag, al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prices.clear();
|
||||
self.abs_changes.clear();
|
||||
self.abs_sum = 0.0;
|
||||
self.prev = None;
|
||||
self.seed_gain = 0.0;
|
||||
self.seed_loss = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveRsi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = AdaptiveRsi::new(14).unwrap();
|
||||
assert_eq!(r.period(), 14);
|
||||
assert_eq!(r.warmup_period(), 15);
|
||||
assert_eq!(r.name(), "AdaptiveRsi");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
let mut r = AdaptiveRsi::new(5).unwrap();
|
||||
let last = r
|
||||
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut r = AdaptiveRsi::new(14).unwrap();
|
||||
for v in r
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=100.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let ready = r
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(r.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
assert_eq!(r.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
|
||||
let mut b = AdaptiveRsi::new(14).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::roofing_filter::RoofingFilter;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
|
||||
const AVG_LENGTH: usize = 3;
|
||||
|
||||
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
|
||||
/// period** of the market by correlating a roofing-filtered price with lagged
|
||||
/// copies of itself and reading off the spectral peak.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = RoofingFilter(price) (detrend + denoise)
|
||||
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
|
||||
/// for each candidate period:
|
||||
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
|
||||
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t−1} (EMA across time)
|
||||
/// normalise by a decaying max, then
|
||||
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
|
||||
/// ```
|
||||
///
|
||||
/// The autocorrelation function emphasises whatever cycle is actually present and
|
||||
/// suppresses noise; transforming it into a periodogram and taking the
|
||||
/// power-weighted centre of gravity gives a smooth, robust estimate of the
|
||||
/// dominant cycle length. That cycle is the key input for every *adaptive*
|
||||
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
|
||||
/// output is a period in bars within `[min_period, max_period]`.
|
||||
///
|
||||
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
|
||||
/// O(`max_period²`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
|
||||
/// use std::f64::consts::TAU;
|
||||
///
|
||||
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocorrelationPeriodogram {
|
||||
min_period: usize,
|
||||
max_period: usize,
|
||||
roof: RoofingFilter,
|
||||
buffer: VecDeque<f64>,
|
||||
r: Vec<f64>,
|
||||
max_pwr: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AutocorrelationPeriodogram {
|
||||
/// Construct an autocorrelation periodogram searching cycles in
|
||||
/// `[min_period, max_period]`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
|
||||
/// `max_period <= min_period`.
|
||||
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
|
||||
if min_period == 0 || max_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_period,
|
||||
max_period,
|
||||
roof: RoofingFilter::new(10, max_period)?,
|
||||
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
|
||||
r: vec![0.0; max_period + 1],
|
||||
max_pwr: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(min_period, max_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.min_period, self.max_period)
|
||||
}
|
||||
|
||||
/// Current dominant-cycle estimate if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
|
||||
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
|
||||
fn correlation(&self, lag: usize) -> f64 {
|
||||
let len = self.buffer.len();
|
||||
let filt = |k: usize| self.buffer[len - 1 - k];
|
||||
let m = AVG_LENGTH as f64;
|
||||
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for count in 0..AVG_LENGTH {
|
||||
let x = filt(count);
|
||||
let y = filt(lag + count);
|
||||
sx += x;
|
||||
sy += y;
|
||||
sxx += x * x;
|
||||
syy += y * y;
|
||||
sxy += x * y;
|
||||
}
|
||||
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
|
||||
if denom > 0.0 {
|
||||
(m * sxy - sx * sy) / denom.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AutocorrelationPeriodogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.roof.update(price)?;
|
||||
if self.buffer.len() == self.max_period + AVG_LENGTH {
|
||||
self.buffer.pop_front();
|
||||
}
|
||||
self.buffer.push_back(filt);
|
||||
if self.buffer.len() < self.max_period + AVG_LENGTH {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Autocorrelation across lags.
|
||||
let mut corr = vec![0.0; self.max_period + 1];
|
||||
for (lag, c) in corr.iter_mut().enumerate() {
|
||||
*c = self.correlation(lag);
|
||||
}
|
||||
|
||||
// Periodogram: spectral power for each candidate period, EMA'd over time.
|
||||
self.max_pwr *= 0.995;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let mut cosine = 0.0;
|
||||
let mut sine = 0.0;
|
||||
for (n, &cn) in corr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(self.max_period + 1)
|
||||
.skip(AVG_LENGTH)
|
||||
{
|
||||
let angle = TAU * n as f64 / period as f64;
|
||||
cosine += cn * angle.cos();
|
||||
sine += cn * angle.sin();
|
||||
}
|
||||
let power = cosine * cosine + sine * sine;
|
||||
self.r[period] = 0.2 * power + 0.8 * self.r[period];
|
||||
if self.r[period] > self.max_pwr {
|
||||
self.max_pwr = self.r[period];
|
||||
}
|
||||
}
|
||||
|
||||
// Power-weighted centre of gravity of the strong periods.
|
||||
let mut spx = 0.0;
|
||||
let mut sp = 0.0;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let pwr = if self.max_pwr > 0.0 {
|
||||
self.r[period] / self.max_pwr
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if pwr >= 0.5 {
|
||||
spx += period as f64 * pwr;
|
||||
sp += pwr;
|
||||
}
|
||||
}
|
||||
let dominant = if sp > 0.0 {
|
||||
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
|
||||
} else {
|
||||
self.min_period as f64
|
||||
};
|
||||
self.last = Some(dominant);
|
||||
Some(dominant)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roof.reset();
|
||||
self.buffer.clear();
|
||||
self.r.iter_mut().for_each(|x| *x = 0.0);
|
||||
self.max_pwr = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.max_period + AVG_LENGTH
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AutocorrelationPeriodogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(0, 48),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(3, 48),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(48, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
assert_eq!(p.periods(), (10, 48));
|
||||
assert_eq!(p.warmup_period(), 51);
|
||||
assert_eq!(p.name(), "AutocorrelationPeriodogram");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
|
||||
let xs: Vec<f64> = (0..40)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out = p.batch(&xs);
|
||||
let warmup = p.warmup_period(); // 23
|
||||
assert_eq!(warmup, 23);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_period_band() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in p.batch(&xs).into_iter().flatten() {
|
||||
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_injected_cycle() {
|
||||
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..600)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
(last - 20.0).abs() < 6.0,
|
||||
"expected ~20-bar cycle, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..80)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = p.value();
|
||||
assert_eq!(p.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
|
||||
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_falls_back_to_min_period() {
|
||||
// Constant input has zero variance, so every lag correlation is
|
||||
// degenerate (denom <= 0), the max power is zero and no period clears
|
||||
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = AutocorrelationPeriodogram::new(10, 48)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
|
||||
/// around a target `period` and rejects both the trend (low frequencies) and the
|
||||
/// noise (high frequencies).
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// beta = cos(2π / period)
|
||||
/// gamma = 1 / cos(4π · bandwidth / period)
|
||||
/// alpha = gamma − sqrt(gamma² − 1)
|
||||
/// BP_t = 0.5·(1 − alpha)·(price_t − price_{t−2})
|
||||
/// + beta·(1 + alpha)·BP_{t−1} − alpha·BP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
|
||||
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
|
||||
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
|
||||
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
|
||||
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
|
||||
/// and cycle-amplitude work.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
|
||||
/// produced every bar. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BandpassFilter};
|
||||
///
|
||||
/// let mut indicator = BandpassFilter::new(20, 0.3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BandpassFilter {
|
||||
period: usize,
|
||||
bandwidth: f64,
|
||||
beta: f64,
|
||||
alpha: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
bp1: f64,
|
||||
bp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BandpassFilter {
|
||||
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
|
||||
/// fraction.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
|
||||
/// `(0, 1)`.
|
||||
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "bandpass bandwidth must be in (0, 1)",
|
||||
});
|
||||
}
|
||||
let period_f = period as f64;
|
||||
let beta = (2.0 * PI / period_f).cos();
|
||||
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
|
||||
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
|
||||
Ok(Self {
|
||||
period,
|
||||
bandwidth,
|
||||
beta,
|
||||
alpha,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
bp1: 0.0,
|
||||
bp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bandwidth)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.bandwidth)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BandpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let bp = match self.prev_price_2 {
|
||||
Some(p2) => {
|
||||
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
|
||||
- self.alpha * self.bp2
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.bp2 = self.bp1;
|
||||
self.bp1 = bp;
|
||||
self.last = Some(bp);
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.bp1 = 0.0;
|
||||
self.bp2 = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BandpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(0, 0.3),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.params(), (20, 0.3));
|
||||
assert_eq!(bp.warmup_period(), 1);
|
||||
assert_eq!(bp.name(), "BandpassFilter");
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
assert_eq!(bp.update(101.0), Some(0.0));
|
||||
// From the third bar the recursion is active.
|
||||
assert!(bp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
// A trend-free flat input has no cyclic content -> output stays 0.
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_oscillates_around_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
let mean = out.iter().sum::<f64>() / out.len() as f64;
|
||||
assert!(
|
||||
mean.abs() < 1.0,
|
||||
"bandpass output should be ~zero mean, got {mean}"
|
||||
);
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = bp.value();
|
||||
assert_eq!(bp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bp.is_ready());
|
||||
bp.reset();
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
|
||||
let mut b = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
|
||||
/// oscillator: how much volume (effort) a bar spent relative to the price range
|
||||
/// (result) it achieved, both normalised against their own recent averages.
|
||||
///
|
||||
/// ```text
|
||||
/// range_t = high_t − low_t
|
||||
/// rel_vol = volume_t / SMA(volume, period)
|
||||
/// rel_range = range_t / SMA(range, period)
|
||||
/// BetterVol = rel_vol − rel_range
|
||||
/// ```
|
||||
///
|
||||
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
|
||||
/// through the relationship between **effort** (volume) and **result** (the bar's
|
||||
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
|
||||
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
|
||||
/// produced little movement, the hallmark of absorption (supply meeting demand at
|
||||
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
|
||||
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
|
||||
///
|
||||
/// Both legs are normalised by their `period` simple moving averages (including
|
||||
/// the current bar), so the output is centred near `0` and self-scales to the
|
||||
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
|
||||
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, BetterVolume};
|
||||
///
|
||||
/// let mut indicator = BetterVolume::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BetterVolume {
|
||||
period: usize,
|
||||
volumes: VecDeque<f64>,
|
||||
ranges: VecDeque<f64>,
|
||||
vol_sum: f64,
|
||||
range_sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BetterVolume {
|
||||
/// Construct a new Better Volume oscillator with the given averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
volumes: VecDeque::with_capacity(period),
|
||||
ranges: VecDeque::with_capacity(period),
|
||||
vol_sum: 0.0,
|
||||
range_sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BetterVolume {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
if self.volumes.len() == self.period {
|
||||
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
|
||||
self.range_sum -= self.ranges.pop_front().expect("non-empty");
|
||||
}
|
||||
self.volumes.push_back(candle.volume);
|
||||
self.ranges.push_back(range);
|
||||
self.vol_sum += candle.volume;
|
||||
self.range_sum += range;
|
||||
if self.volumes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let sma_vol = self.vol_sum / n;
|
||||
let sma_range = self.range_sum / n;
|
||||
let rel_vol = if sma_vol > 0.0 {
|
||||
candle.volume / sma_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rel_range = if sma_range > 0.0 {
|
||||
range / sma_range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = rel_vol - rel_range;
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.volumes.clear();
|
||||
self.ranges.clear();
|
||||
self.vol_sum = 0.0;
|
||||
self.range_sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BetterVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, high, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bv = BetterVolume::new(20).unwrap();
|
||||
assert_eq!(bv.period(), 20);
|
||||
assert_eq!(bv.warmup_period(), 20);
|
||||
assert_eq!(bv.name(), "BetterVolume");
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
|
||||
let out = bv.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_bars_are_neutral() {
|
||||
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn churn_bar_is_positive() {
|
||||
// Three normal bars, then a high-volume narrow-range bar -> positive.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
|
||||
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "churn bar should be positive, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ease_of_movement_bar_is_negative() {
|
||||
// Three normal bars, then a wide-range light-volume bar -> negative.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
|
||||
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"ease-of-movement bar should be negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_everything_is_zero() {
|
||||
// Zero volume and zero range -> both legs guarded to 0.
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
|
||||
for v in bv.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
bv.batch(
|
||||
&(0..6)
|
||||
.map(|_| candle(102.0, 100.0, 1_000.0))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(bv.is_ready());
|
||||
bv.reset();
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.value(), None);
|
||||
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
candle(
|
||||
base + 2.0,
|
||||
base - 1.5,
|
||||
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = BetterVolume::new(20).unwrap().batch(&candles);
|
||||
let mut b = BetterVolume::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation
|
||||
/// coefficient between price and a perfectly straight ramp over the lookback.
|
||||
///
|
||||
/// ```text
|
||||
/// CTI = corr( price over the window , [0, 1, …, period−1] )
|
||||
/// ```
|
||||
///
|
||||
/// John Ehlers' CTI asks "how closely does recent price track a straight line?"
|
||||
/// by correlating the windowed price against the time index itself. A reading near
|
||||
/// `+1` means price is rising in a near-perfect line (strong uptrend); near `−1`
|
||||
/// means a clean downtrend; near `0` means no linear trend (a range or choppy
|
||||
/// market). Because correlation is scale- and offset-invariant, the slope's
|
||||
/// steepness does not matter — only how *linear* the move is — which makes CTI an
|
||||
/// unusually clean trend/range classifier. It differs from
|
||||
/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a
|
||||
/// *lagged copy of itself* rather than with time.
|
||||
///
|
||||
/// The output is in `[−1, +1]`; a flat window (zero price variance) returns `0`.
|
||||
/// The first value lands after `period` inputs; each `update` recomputes the
|
||||
/// correlation over the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CorrelationTrendIndicator};
|
||||
///
|
||||
/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrelationTrendIndicator {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl CorrelationTrendIndicator {
|
||||
/// Construct a CTI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two
|
||||
/// points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "CTI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
let mut sum_xt = 0.0;
|
||||
for (i, &x) in self.window.iter().enumerate() {
|
||||
let t = i as f64;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
sum_xt += x * t;
|
||||
}
|
||||
// Time index 0..n-1 has closed-form sums.
|
||||
let sum_t = n * (n - 1.0) / 2.0;
|
||||
let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
|
||||
let cov = n * sum_xt - sum_x * sum_t;
|
||||
let var_x = n * sum_xx - sum_x * sum_x;
|
||||
let var_t = n * sum_tt - sum_t * sum_t;
|
||||
let denom = (var_x * var_t).sqrt();
|
||||
if denom == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(cov / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CorrelationTrendIndicator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CorrelationTrendIndicator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
CorrelationTrendIndicator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(CorrelationTrendIndicator::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cti = CorrelationTrendIndicator::new(20).unwrap();
|
||||
assert_eq!(cti.period(), 20);
|
||||
assert_eq!(cti.warmup_period(), 20);
|
||||
assert_eq!(cti.name(), "CorrelationTrendIndicator");
|
||||
assert!(!cti.is_ready());
|
||||
assert_eq!(cti.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_uptrend_is_one() {
|
||||
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
|
||||
let last = cti
|
||||
.batch(&(0..40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_downtrend_is_minus_one() {
|
||||
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
|
||||
let last = cti
|
||||
.batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut cti = CorrelationTrendIndicator::new(8).unwrap();
|
||||
let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut cti = CorrelationTrendIndicator::new(20).unwrap();
|
||||
for v in cti
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
let ready = cti
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(cti.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
cti.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(cti.is_ready());
|
||||
cti.reset();
|
||||
assert!(!cti.is_ready());
|
||||
assert_eq!(cti.value(), None);
|
||||
assert_eq!(cti.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs);
|
||||
let mut b = CorrelationTrendIndicator::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1].
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator
|
||||
/// that swings cleanly in `[−1, +1]` regardless of price amplitude.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12):
|
||||
///
|
||||
/// ```text
|
||||
/// alpha1 = (1 − sin(2π/hp_period)) / cos(2π/hp_period)
|
||||
/// HP_t = 0.5·(1 + alpha1)·(price_t − price_{t−1}) + alpha1·HP_{t−1} (one-pole highpass)
|
||||
/// Filt = SuperSmoother(HP, ssf_length)
|
||||
/// Wave = (Filt_t + Filt_{t−1} + Filt_{t−2}) / 3
|
||||
/// Pwr = (Filt_t² + Filt_{t−1}² + Filt_{t−2}²) / 3
|
||||
/// EBSW = Wave / sqrt(Pwr)
|
||||
/// ```
|
||||
///
|
||||
/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
|
||||
/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
|
||||
/// cycle by its RMS power normalises the amplitude, so the output reads like a
|
||||
/// clean sine wave bounded in `[−1, +1]` whatever the instrument. Unlike the
|
||||
/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
|
||||
/// components from the Hilbert transform and can whip in trends), the EBSW stays
|
||||
/// well-behaved and is read directly: crossing up through `0`/`−0.9` is a buy
|
||||
/// cue, crossing down through `0`/`+0.9` a sell cue.
|
||||
///
|
||||
/// The first value lands once three SuperSmoothed samples exist
|
||||
/// (`warmup_period == 3`). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, EvenBetterSinewave};
|
||||
///
|
||||
/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvenBetterSinewave {
|
||||
hp_period: usize,
|
||||
ssf_length: usize,
|
||||
alpha1: f64,
|
||||
smoother: SuperSmoother,
|
||||
prev_price: Option<f64>,
|
||||
hp: f64,
|
||||
filt1: Option<f64>,
|
||||
filt2: Option<f64>,
|
||||
filt3: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl EvenBetterSinewave {
|
||||
/// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
|
||||
/// `ssf_length`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either argument is `0`.
|
||||
pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
|
||||
if hp_period == 0 || ssf_length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let w = 2.0 * PI / hp_period as f64;
|
||||
let alpha1 = (1.0 - w.sin()) / w.cos();
|
||||
Ok(Self {
|
||||
hp_period,
|
||||
ssf_length,
|
||||
alpha1,
|
||||
smoother: SuperSmoother::new(ssf_length)?,
|
||||
prev_price: None,
|
||||
hp: 0.0,
|
||||
filt1: None,
|
||||
filt2: None,
|
||||
filt3: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(hp_period, ssf_length)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.hp_period, self.ssf_length)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EvenBetterSinewave {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let hp = match self.prev_price {
|
||||
Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp,
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price = Some(price);
|
||||
self.hp = hp;
|
||||
let filt = self.smoother.update(hp)?;
|
||||
// Shift the three-deep filter buffer.
|
||||
self.filt3 = self.filt2;
|
||||
self.filt2 = self.filt1;
|
||||
self.filt1 = Some(filt);
|
||||
let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
|
||||
return None;
|
||||
};
|
||||
let wave = (f1 + f2 + f3) / 3.0;
|
||||
let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
|
||||
let ebsw = if pwr > 0.0 {
|
||||
(wave / pwr.sqrt()).clamp(-1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(ebsw);
|
||||
Some(ebsw)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.prev_price = None;
|
||||
self.hp = 0.0;
|
||||
self.filt1 = None;
|
||||
self.filt2 = None;
|
||||
self.filt3 = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EvenBetterSinewave"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(
|
||||
EvenBetterSinewave::new(0, 10),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
EvenBetterSinewave::new(40, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
assert_eq!(e.params(), (40, 10));
|
||||
assert_eq!(e.warmup_period(), 3);
|
||||
assert_eq!(e.name(), "EvenBetterSinewave");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let xs: Vec<f64> = (0..12)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
|
||||
.collect();
|
||||
let out = e.batch(&xs);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in e.batch(&xs).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_swings_both_signs() {
|
||||
let mut e = EvenBetterSinewave::new(30, 8).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
e.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = e.value();
|
||||
assert_eq!(e.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
e.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
|
||||
let mut b = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_yields_zero_power() {
|
||||
// A constant series drives the highpass/smoother outputs to zero, so the
|
||||
// signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = EvenBetterSinewave::new(40, 10)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price
|
||||
/// series, leaving the higher-frequency cyclic and noise content.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// a = 0.707 · 2π / period
|
||||
/// alpha1 = (cos(a) + sin(a) − 1) / cos(a)
|
||||
/// HP_t = (1 − alpha1/2)² · (price_t − 2·price_{t−1} + price_{t−2})
|
||||
/// + 2·(1 − alpha1)·HP_{t−1} − (1 − alpha1)²·HP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// A highpass filter is the complement of a smoother: where a lowpass keeps the
|
||||
/// trend, the highpass keeps everything *faster* than the cutoff `period`. The
|
||||
/// two-pole design gives a steep roll-off so frequencies below the cutoff are
|
||||
/// firmly removed, detrending the series into a zero-mean wave. This differs from
|
||||
/// the [`Decycler`](crate::Decycler), which is `price − highpass` (the *trend* that
|
||||
/// remains); the highpass is the cyclic part that the decycler discards.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0`, so `warmup_period` is `1`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HighpassFilter};
|
||||
///
|
||||
/// let mut indicator = HighpassFilter::new(48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin() * 3.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighpassFilter {
|
||||
period: usize,
|
||||
alpha1: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
hp1: f64,
|
||||
hp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HighpassFilter {
|
||||
/// Construct a two-pole highpass filter with the given cutoff `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let a = 0.707 * 2.0 * PI / period as f64;
|
||||
let alpha1 = (a.cos() + a.sin() - 1.0) / a.cos();
|
||||
Ok(Self {
|
||||
period,
|
||||
alpha1,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
hp1: 0.0,
|
||||
hp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured cutoff period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HighpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let hp = match (self.prev_price_1, self.prev_price_2) {
|
||||
(Some(p1), Some(p2)) => {
|
||||
let one_minus = 1.0 - self.alpha1;
|
||||
let half = 1.0 - self.alpha1 / 2.0;
|
||||
half * half * (price - 2.0 * p1 + p2) + 2.0 * one_minus * self.hp1
|
||||
- one_minus * one_minus * self.hp2
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.hp2 = self.hp1;
|
||||
self.hp1 = hp;
|
||||
self.last = Some(hp);
|
||||
Some(hp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.hp1 = 0.0;
|
||||
self.hp2 = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HighpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(HighpassFilter::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hp = HighpassFilter::new(48).unwrap();
|
||||
assert_eq!(hp.period(), 48);
|
||||
assert_eq!(hp.warmup_period(), 1);
|
||||
assert_eq!(hp.name(), "HighpassFilter");
|
||||
assert!(!hp.is_ready());
|
||||
assert_eq!(hp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
assert_eq!(hp.update(100.0), Some(0.0));
|
||||
assert_eq!(hp.update(101.0), Some(0.0));
|
||||
assert!(hp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
for v in hp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_trend_is_attenuated() {
|
||||
// A straight ramp is low-frequency -> the highpass should drive its
|
||||
// output small after warmup (the trend is removed).
|
||||
let mut hp = HighpassFilter::new(20).unwrap();
|
||||
let out: Vec<f64> = hp
|
||||
.batch(&(0..400).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(200)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v.abs() < 5.0, "trend should be attenuated, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = hp.value();
|
||||
assert_eq!(hp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(hp.is_ready());
|
||||
hp.reset();
|
||||
assert!(!hp.is_ready());
|
||||
assert_eq!(hp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + f64::from(i) + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = HighpassFilter::new(48).unwrap().batch(&xs);
|
||||
let mut b = HighpassFilter::new(48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
|
||||
/// bar's volume by where the close lands inside the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// II_t = volume * (2*close − high − low) / (high − low) (0 if high == low)
|
||||
/// III_t = III_{t−1} + II_t
|
||||
/// ```
|
||||
///
|
||||
/// The fraction `(2*close − high − low) / (high − low)` is `+1` when the bar
|
||||
/// closes on its high, `−1` when it closes on its low, and `0` at the midpoint.
|
||||
/// Scaling it by volume and accumulating produces a running measure of how
|
||||
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
|
||||
/// for institutional accumulation (rising line) or distribution (falling line).
|
||||
///
|
||||
/// This is the **cumulative** Intraday Intensity (the original index), not the
|
||||
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
|
||||
/// by a windowed sum of volume and is mathematically identical to
|
||||
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
|
||||
/// arbitrary; only its slope and divergences against price matter. A doji whose
|
||||
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
|
||||
/// already emits a value.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
|
||||
///
|
||||
/// let mut indicator = IntradayIntensity::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntradayIntensity {
|
||||
iii: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl IntradayIntensity {
|
||||
/// Construct a new Intraday Intensity Index. The line is parameter-free.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayIntensity {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let ii = if range > 0.0 {
|
||||
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.iii += ii;
|
||||
self.last = Some(self.iii);
|
||||
Some(self.iii)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.iii = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IntradayIntensity"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let iii = IntradayIntensity::new();
|
||||
assert_eq!(iii.warmup_period(), 1);
|
||||
assert_eq!(iii.name(), "IntradayIntensity");
|
||||
assert!(!iii.is_ready());
|
||||
assert_eq!(iii.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_emits() {
|
||||
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
|
||||
let mut iii = IntradayIntensity::new();
|
||||
// close on the high -> +1 * volume.
|
||||
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
|
||||
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_high_adds_full_volume() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_low_subtracts_full_volume() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_midpoint_adds_nothing() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_adds_nothing() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_across_bars() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
|
||||
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
|
||||
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
iii.batch(&[
|
||||
candle(110.0, 100.0, 108.0, 1.0),
|
||||
candle(110.0, 100.0, 102.0, 1.0),
|
||||
]);
|
||||
assert!(iii.is_ready());
|
||||
iii.reset();
|
||||
assert!(!iii.is_ready());
|
||||
assert_eq!(iii.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = IntradayIntensity::new().batch(&candles);
|
||||
let mut b = IntradayIntensity::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Jarque-Bera — a normality-test statistic on a rolling window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's
|
||||
/// distribution departs from normal, via its **skewness** and **excess
|
||||
/// kurtosis**.
|
||||
///
|
||||
/// ```text
|
||||
/// S = skewness = m3 / m2^(3/2)
|
||||
/// K = excess kurtosis = m4 / m2² − 3
|
||||
/// JB = (period / 6) · ( S² + K²/4 )
|
||||
/// ```
|
||||
///
|
||||
/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the
|
||||
/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so
|
||||
/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero
|
||||
/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of
|
||||
/// normality `JB` is asymptotically χ² with two degrees of freedom, so values
|
||||
/// above roughly `6` reject normality at the 95% level — a useful streaming flag
|
||||
/// for fat-tail / crash-risk regimes in a return series.
|
||||
///
|
||||
/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`)
|
||||
/// returns `0`. The first value lands after `period` inputs; each `update`
|
||||
/// recomputes the four moments over the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, JarqueBera};
|
||||
///
|
||||
/// let mut indicator = JarqueBera::new(50).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin());
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JarqueBera {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl JarqueBera {
|
||||
/// Construct a rolling Jarque-Bera over `period` values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on
|
||||
/// fewer than four points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 4 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Jarque-Bera needs period >= 4",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mean = self.window.iter().sum::<f64>() / n;
|
||||
let mut m2 = 0.0;
|
||||
let mut m3 = 0.0;
|
||||
let mut m4 = 0.0;
|
||||
for &v in &self.window {
|
||||
let d = v - mean;
|
||||
let d2 = d * d;
|
||||
m2 += d2;
|
||||
m3 += d2 * d;
|
||||
m4 += d2 * d2;
|
||||
}
|
||||
m2 /= n;
|
||||
m3 /= n;
|
||||
m4 /= n;
|
||||
if m2 == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let skew = m3 / m2.powf(1.5);
|
||||
let excess_kurt = m4 / (m2 * m2) - 3.0;
|
||||
(n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for JarqueBera {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"JarqueBera"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
JarqueBera::new(3),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(JarqueBera::new(4).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let jb = JarqueBera::new(50).unwrap();
|
||||
assert_eq!(jb.period(), 50);
|
||||
assert_eq!(jb.warmup_period(), 50);
|
||||
assert_eq!(jb.name(), "JarqueBera");
|
||||
assert!(!jb.is_ready());
|
||||
assert_eq!(jb.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut jb = JarqueBera::new(8).unwrap();
|
||||
let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut jb = JarqueBera::new(30).unwrap();
|
||||
for v in jb
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(v >= 0.0, "JB must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skewed_window_exceeds_symmetric() {
|
||||
// A symmetric window vs. one with a heavy outlier (high skew + kurtosis).
|
||||
let symmetric: Vec<f64> = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0];
|
||||
let skewed: Vec<f64> = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0];
|
||||
let jb_sym = JarqueBera::new(8)
|
||||
.unwrap()
|
||||
.batch(&symmetric)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
let jb_skew = JarqueBera::new(8)
|
||||
.unwrap()
|
||||
.batch(&skewed)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
jb_skew > jb_sym,
|
||||
"skewed ({jb_skew}) should exceed symmetric ({jb_sym})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
let ready = jb
|
||||
.batch(&[1.0, 2.0, 3.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(jb.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
jb.batch(&[1.0, 2.0, 3.0, 5.0]);
|
||||
assert!(jb.is_ready());
|
||||
jb.reset();
|
||||
assert!(!jb.is_ready());
|
||||
assert_eq!(jb.value(), None);
|
||||
assert_eq!(jb.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = JarqueBera::new(30).unwrap().batch(&xs);
|
||||
let mut b = JarqueBera::new(30).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `+1` / `0` / `-1` sign of `a − b`.
|
||||
fn sign(a: f64, b: f64) -> i32 {
|
||||
if a > b {
|
||||
1
|
||||
} else if a < b {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Kendall's tau-b — a rank correlation between two synchronised series based on
|
||||
/// the balance of **concordant** and **discordant** pairs, with a tie correction.
|
||||
///
|
||||
/// ```text
|
||||
/// over all pairs (i < j) in the window:
|
||||
/// concordant if (x_j − x_i) and (y_j − y_i) share a sign
|
||||
/// discordant if they have opposite signs
|
||||
/// tie_x / tie_y if the respective difference is zero
|
||||
/// n0 = N(N−1)/2
|
||||
/// tau_b = (n_concordant − n_discordant) / sqrt((n0 − tie_x)(n0 − tie_y))
|
||||
/// ```
|
||||
///
|
||||
/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear*
|
||||
/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates
|
||||
/// ranks via their differences, Kendall's tau counts how often the two series move
|
||||
/// the **same direction** between every pair of observations. It is the most
|
||||
/// robust of the three to outliers and to non-linear-but-monotonic
|
||||
/// relationships, and the tau-b form corrects for ties so repeated values do not
|
||||
/// bias it. The output is in `[−1, +1]`: `+1` perfectly concordant, `−1`
|
||||
/// perfectly discordant, `0` no monotonic association.
|
||||
///
|
||||
/// The window holds the last `period` pairs and is recomputed each bar in
|
||||
/// O(`period²`). A window with no untied pairs on one side returns `0`. The first
|
||||
/// value lands after `period` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KendallTau};
|
||||
///
|
||||
/// let mut indicator = KendallTau::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let x = f64::from(i);
|
||||
/// last = indicator.update((x, 2.0 * x)); // perfectly concordant
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KendallTau {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl KendallTau {
|
||||
/// Construct a rolling Kendall's tau-b over `period` pairs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at
|
||||
/// least two pairs).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Kendall tau needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of pairs.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let pairs: Vec<(f64, f64)> = self.window.iter().copied().collect();
|
||||
let len = pairs.len();
|
||||
let mut concordant: i64 = 0;
|
||||
let mut discordant: i64 = 0;
|
||||
let mut tie_x: i64 = 0;
|
||||
let mut tie_y: i64 = 0;
|
||||
for i in 0..len {
|
||||
for j in (i + 1)..len {
|
||||
let sx = sign(pairs[j].0, pairs[i].0);
|
||||
let sy = sign(pairs[j].1, pairs[i].1);
|
||||
if sx == 0 {
|
||||
tie_x += 1;
|
||||
}
|
||||
if sy == 0 {
|
||||
tie_y += 1;
|
||||
}
|
||||
let prod = sx * sy;
|
||||
if prod > 0 {
|
||||
concordant += 1;
|
||||
} else if prod < 0 {
|
||||
discordant += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n0 = (len * (len - 1) / 2) as f64;
|
||||
let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt();
|
||||
if denom == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KendallTau {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KendallTau"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
KendallTau::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(KendallTau::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KendallTau::new(20).unwrap();
|
||||
assert_eq!(k.period(), 20);
|
||||
assert_eq!(k.warmup_period(), 20);
|
||||
assert_eq!(k.name(), "KendallTau");
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_increasing_is_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let last = KendallTau::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_decreasing_is_minus_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| (f64::from(i), -3.0 * f64::from(i)))
|
||||
.collect();
|
||||
let last = KendallTau::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_channel_yields_zero() {
|
||||
// y constant -> every y-difference is a tie -> denom 0 -> 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = KendallTau::new(8)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
for v in KendallTau::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
assert_eq!(k.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin(), (t * 0.5).cos())
|
||||
})
|
||||
.collect();
|
||||
let batch = KendallTau::new(14).unwrap().batch(&pairs);
|
||||
let mut b = KendallTau::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_are_corrected() {
|
||||
// Tied x values (points 0 and 1) and tied y values (points 1 and 2)
|
||||
// exercise the tie_x / tie_y correction counters.
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
assert_eq!(k.update((1.0, 1.0)), None);
|
||||
assert_eq!(k.update((1.0, 2.0)), None);
|
||||
assert_eq!(k.update((2.0, 2.0)), None);
|
||||
let v = k.update((3.0, 3.0)).unwrap();
|
||||
assert!((-1.0..=1.0).contains(&v), "got {v}");
|
||||
}
|
||||
}
|
||||
@@ -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,8 +16,10 @@ mod acceleration_bands;
|
||||
mod accelerator_oscillator;
|
||||
mod ad_oscillator;
|
||||
mod ad_volume_line;
|
||||
mod adaptive_cci;
|
||||
mod adaptive_cycle;
|
||||
mod adaptive_laguerre_filter;
|
||||
mod adaptive_rsi;
|
||||
mod adl;
|
||||
mod advance_block;
|
||||
mod advance_decline;
|
||||
@@ -30,6 +32,7 @@ mod alpha;
|
||||
mod amihud_illiquidity;
|
||||
mod anchored_rsi;
|
||||
mod anchored_vwap;
|
||||
mod andrews_pitchfork;
|
||||
mod apo;
|
||||
mod aroon;
|
||||
mod aroon_oscillator;
|
||||
@@ -39,16 +42,19 @@ mod atr_ratchet;
|
||||
mod atr_trailing_stop;
|
||||
mod auto_fib;
|
||||
mod autocorrelation;
|
||||
mod autocorrelation_periodogram;
|
||||
mod average_daily_range;
|
||||
mod average_drawdown;
|
||||
mod avg_price;
|
||||
mod awesome_oscillator;
|
||||
mod awesome_oscillator_histogram;
|
||||
mod balance_of_power;
|
||||
mod bandpass_filter;
|
||||
mod bat;
|
||||
mod belt_hold;
|
||||
mod beta;
|
||||
mod beta_neutral_spread;
|
||||
mod better_volume;
|
||||
mod bipower_variation;
|
||||
mod body_size_pct;
|
||||
mod bollinger;
|
||||
@@ -63,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;
|
||||
@@ -80,6 +87,7 @@ mod concealing_baby_swallow;
|
||||
mod conditional_value_at_risk;
|
||||
mod connors_rsi;
|
||||
mod coppock;
|
||||
mod correlation_trend_indicator;
|
||||
mod counterattack;
|
||||
mod crab;
|
||||
mod cumulative_volume_index;
|
||||
@@ -120,6 +128,7 @@ mod elder_safezone;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod even_better_sinewave;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
mod ewma_volatility;
|
||||
@@ -165,6 +174,7 @@ mod heikin_ashi;
|
||||
mod high_low_index;
|
||||
mod high_low_range;
|
||||
mod high_wave;
|
||||
mod highpass_filter;
|
||||
mod hikkake;
|
||||
mod hikkake_modified;
|
||||
mod hilbert_dominant_cycle;
|
||||
@@ -185,10 +195,12 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_intensity;
|
||||
mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
mod jarque_bera;
|
||||
mod jma;
|
||||
mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
@@ -198,6 +210,7 @@ mod kase_devstop;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
mod kendall_tau;
|
||||
mod kicking;
|
||||
mod kicking_by_length;
|
||||
mod kst;
|
||||
@@ -246,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;
|
||||
@@ -275,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;
|
||||
@@ -296,6 +311,7 @@ mod realized_spread;
|
||||
mod realized_volatility;
|
||||
mod recovery_factor;
|
||||
mod rectangle_range;
|
||||
mod reflex;
|
||||
mod regime_label;
|
||||
mod relative_strength_ab;
|
||||
mod renko_bars;
|
||||
@@ -312,6 +328,7 @@ mod roll_measure;
|
||||
mod rolling_correlation;
|
||||
mod rolling_covariance;
|
||||
mod rolling_iqr;
|
||||
mod rolling_min_max_scaler;
|
||||
mod rolling_percentile_rank;
|
||||
mod rolling_quantile;
|
||||
mod roofing_filter;
|
||||
@@ -320,12 +337,14 @@ mod rsx;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
mod sample_entropy;
|
||||
mod sar_ext;
|
||||
mod seasonal_z_score;
|
||||
mod separating_lines;
|
||||
mod session_high_low;
|
||||
mod session_range;
|
||||
mod session_vwap;
|
||||
mod shannon_entropy;
|
||||
mod shark;
|
||||
mod sharpe_ratio;
|
||||
mod shooting_star;
|
||||
@@ -360,18 +379,25 @@ mod t3;
|
||||
mod taker_buy_sell_ratio;
|
||||
mod takuri;
|
||||
mod tasuki_gap;
|
||||
mod td_camouflage;
|
||||
mod td_clop;
|
||||
mod td_clopwin;
|
||||
mod td_combo;
|
||||
mod td_countdown;
|
||||
mod td_demarker;
|
||||
mod td_differential;
|
||||
mod td_dwave;
|
||||
mod td_lines;
|
||||
mod td_moving_average;
|
||||
mod td_open;
|
||||
mod td_pressure;
|
||||
mod td_propulsion;
|
||||
mod td_range_projection;
|
||||
mod td_rei;
|
||||
mod td_risk_level;
|
||||
mod td_sequential;
|
||||
mod td_setup;
|
||||
mod td_trap;
|
||||
mod tema;
|
||||
mod term_structure_basis;
|
||||
mod three_drives;
|
||||
@@ -387,8 +413,10 @@ mod time_based_stop;
|
||||
mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trade_volume_index;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
mod trendflex;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
@@ -404,11 +432,13 @@ mod ttm_squeeze;
|
||||
mod ttm_trend;
|
||||
mod turn_of_month;
|
||||
mod tweezer;
|
||||
mod twiggs_money_flow;
|
||||
mod two_crows;
|
||||
mod typical_price;
|
||||
mod ulcer_index;
|
||||
mod ultimate_oscillator;
|
||||
mod unique_three_river;
|
||||
mod universal_oscillator;
|
||||
mod up_down_volume_ratio;
|
||||
mod upside_gap_three_methods;
|
||||
mod upside_gap_two_crows;
|
||||
@@ -425,6 +455,9 @@ mod volty_stop;
|
||||
mod volume_by_time_profile;
|
||||
mod volume_oscillator;
|
||||
mod volume_profile;
|
||||
mod volume_rsi;
|
||||
mod volume_weighted_macd;
|
||||
mod volume_weighted_sr;
|
||||
mod vortex;
|
||||
mod vpin;
|
||||
mod vpt;
|
||||
@@ -432,6 +465,7 @@ mod vwap;
|
||||
mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wad;
|
||||
mod wave_pm;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
@@ -456,8 +490,10 @@ pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
|
||||
pub use accelerator_oscillator::AcceleratorOscillator;
|
||||
pub use ad_oscillator::AdOscillator;
|
||||
pub use ad_volume_line::AdVolumeLine;
|
||||
pub use adaptive_cci::AdaptiveCci;
|
||||
pub use adaptive_cycle::AdaptiveCycle;
|
||||
pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter;
|
||||
pub use adaptive_rsi::AdaptiveRsi;
|
||||
pub use adl::Adl;
|
||||
pub use advance_block::AdvanceBlock;
|
||||
pub use advance_decline::AdvanceDecline;
|
||||
@@ -470,6 +506,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;
|
||||
@@ -479,16 +516,19 @@ pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
|
||||
pub use atr_trailing_stop::AtrTrailingStop;
|
||||
pub use auto_fib::{AutoFib, AutoFibOutput};
|
||||
pub use autocorrelation::Autocorrelation;
|
||||
pub use autocorrelation_periodogram::AutocorrelationPeriodogram;
|
||||
pub use average_daily_range::AverageDailyRange;
|
||||
pub use average_drawdown::AverageDrawdown;
|
||||
pub use avg_price::AvgPrice;
|
||||
pub use awesome_oscillator::AwesomeOscillator;
|
||||
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
|
||||
pub use balance_of_power::BalanceOfPower;
|
||||
pub use bandpass_filter::BandpassFilter;
|
||||
pub use bat::Bat;
|
||||
pub use belt_hold::BeltHold;
|
||||
pub use beta::Beta;
|
||||
pub use beta_neutral_spread::BetaNeutralSpread;
|
||||
pub use better_volume::BetterVolume;
|
||||
pub use bipower_variation::BipowerVariation;
|
||||
pub use body_size_pct::BodySizePct;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
@@ -503,6 +543,7 @@ pub use calmar_ratio::CalmarRatio;
|
||||
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
|
||||
pub use cci::Cci;
|
||||
pub use center_of_gravity::CenterOfGravity;
|
||||
pub use central_pivot_range::{CentralPivotRange, CentralPivotRangeOutput};
|
||||
pub use cfo::Cfo;
|
||||
pub use chaikin_oscillator::ChaikinOscillator;
|
||||
pub use chaikin_volatility::ChaikinVolatility;
|
||||
@@ -520,6 +561,7 @@ pub use concealing_baby_swallow::ConcealingBabySwallow;
|
||||
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||
pub use connors_rsi::ConnorsRsi;
|
||||
pub use coppock::Coppock;
|
||||
pub use correlation_trend_indicator::CorrelationTrendIndicator;
|
||||
pub use counterattack::Counterattack;
|
||||
pub use crab::Crab;
|
||||
pub use cumulative_volume_index::CumulativeVolumeIndex;
|
||||
@@ -560,6 +602,7 @@ pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use even_better_sinewave::EvenBetterSinewave;
|
||||
pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
pub use ewma_volatility::EwmaVolatility;
|
||||
@@ -605,6 +648,7 @@ pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use high_low_index::HighLowIndex;
|
||||
pub use high_low_range::HighLowRange;
|
||||
pub use high_wave::HighWave;
|
||||
pub use highpass_filter::HighpassFilter;
|
||||
pub use hikkake::Hikkake;
|
||||
pub use hikkake_modified::HikkakeModified;
|
||||
pub use hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
@@ -625,10 +669,12 @@ pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use intraday_intensity::IntradayIntensity;
|
||||
pub use intraday_momentum_index::IntradayMomentumIndex;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
pub use jarque_bera::JarqueBera;
|
||||
pub use jma::Jma;
|
||||
pub use jump_indicator::JumpIndicator;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
@@ -638,6 +684,7 @@ pub use kase_devstop::{KaseDevStop, KaseDevStopOutput};
|
||||
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use kendall_tau::KendallTau;
|
||||
pub use kicking::Kicking;
|
||||
pub use kicking_by_length::KickingByLength;
|
||||
pub use kst::{Kst, KstOutput};
|
||||
@@ -686,6 +733,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};
|
||||
@@ -715,6 +763,7 @@ pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use pgo::Pgo;
|
||||
pub use piercing_dark_cloud::PiercingDarkCloud;
|
||||
pub use pivot_reversal::PivotReversal;
|
||||
pub use plus_di::PlusDi;
|
||||
pub use plus_dm::PlusDm;
|
||||
pub use pmo::Pmo;
|
||||
@@ -736,6 +785,7 @@ pub use realized_spread::RealizedSpread;
|
||||
pub use realized_volatility::RealizedVolatility;
|
||||
pub use recovery_factor::RecoveryFactor;
|
||||
pub use rectangle_range::RectangleRange;
|
||||
pub use reflex::Reflex;
|
||||
pub use regime_label::RegimeLabel;
|
||||
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||
pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
@@ -752,6 +802,7 @@ pub use roll_measure::RollMeasure;
|
||||
pub use rolling_correlation::RollingCorrelation;
|
||||
pub use rolling_covariance::RollingCovariance;
|
||||
pub use rolling_iqr::RollingIqr;
|
||||
pub use rolling_min_max_scaler::RollingMinMaxScaler;
|
||||
pub use rolling_percentile_rank::RollingPercentileRank;
|
||||
pub use rolling_quantile::RollingQuantile;
|
||||
pub use roofing_filter::RoofingFilter;
|
||||
@@ -760,12 +811,14 @@ pub use rsx::Rsx;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
pub use sample_entropy::SampleEntropy;
|
||||
pub use sar_ext::SarExt;
|
||||
pub use seasonal_z_score::SeasonalZScore;
|
||||
pub use separating_lines::SeparatingLines;
|
||||
pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
|
||||
pub use session_range::{SessionRange, SessionRangeOutput};
|
||||
pub use session_vwap::SessionVwap;
|
||||
pub use shannon_entropy::ShannonEntropy;
|
||||
pub use shark::Shark;
|
||||
pub use sharpe_ratio::SharpeRatio;
|
||||
pub use shooting_star::ShootingStar;
|
||||
@@ -800,18 +853,25 @@ pub use t3::T3;
|
||||
pub use taker_buy_sell_ratio::TakerBuySellRatio;
|
||||
pub use takuri::Takuri;
|
||||
pub use tasuki_gap::TasukiGap;
|
||||
pub use td_camouflage::TdCamouflage;
|
||||
pub use td_clop::TdClop;
|
||||
pub use td_clopwin::TdClopwin;
|
||||
pub use td_combo::TdCombo;
|
||||
pub use td_countdown::TdCountdown;
|
||||
pub use td_demarker::TdDeMarker;
|
||||
pub use td_differential::TdDifferential;
|
||||
pub use td_dwave::TdDWave;
|
||||
pub use td_lines::{TdLines, TdLinesOutput};
|
||||
pub use td_moving_average::{TdMovingAverage, TdMovingAverageOutput};
|
||||
pub use td_open::TdOpen;
|
||||
pub use td_pressure::TdPressure;
|
||||
pub use td_propulsion::TdPropulsion;
|
||||
pub use td_range_projection::{TdRangeProjection, TdRangeProjectionOutput};
|
||||
pub use td_rei::TdRei;
|
||||
pub use td_risk_level::{TdRiskLevel, TdRiskLevelOutput};
|
||||
pub use td_sequential::{TdSequential, TdSequentialOutput};
|
||||
pub use td_setup::TdSetup;
|
||||
pub use td_trap::TdTrap;
|
||||
pub use tema::Tema;
|
||||
pub use term_structure_basis::TermStructureBasis;
|
||||
pub use three_drives::ThreeDrives;
|
||||
@@ -827,8 +887,10 @@ pub use time_based_stop::TimeBasedStop;
|
||||
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trade_volume_index::TradeVolumeIndex;
|
||||
pub use trend_label::TrendLabel;
|
||||
pub use trend_strength_index::TrendStrengthIndex;
|
||||
pub use trendflex::Trendflex;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
@@ -844,11 +906,13 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
|
||||
pub use ttm_trend::TtmTrend;
|
||||
pub use turn_of_month::TurnOfMonth;
|
||||
pub use tweezer::Tweezer;
|
||||
pub use twiggs_money_flow::TwiggsMoneyFlow;
|
||||
pub use two_crows::TwoCrows;
|
||||
pub use typical_price::TypicalPrice;
|
||||
pub use ulcer_index::UlcerIndex;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
pub use unique_three_river::UniqueThreeRiver;
|
||||
pub use universal_oscillator::UniversalOscillator;
|
||||
pub use up_down_volume_ratio::UpDownVolumeRatio;
|
||||
pub use upside_gap_three_methods::UpsideGapThreeMethods;
|
||||
pub use upside_gap_two_crows::UpsideGapTwoCrows;
|
||||
@@ -865,6 +929,9 @@ pub use volty_stop::VoltyStop;
|
||||
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
|
||||
pub use volume_oscillator::VolumeOscillator;
|
||||
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
|
||||
pub use volume_rsi::VolumeRsi;
|
||||
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
|
||||
pub use volume_weighted_sr::{VolumeWeightedSr, VolumeWeightedSrOutput};
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
pub use vpin::Vpin;
|
||||
pub use vpt::VolumePriceTrend;
|
||||
@@ -872,6 +939,7 @@ pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use vzo::Vzo;
|
||||
pub use wad::Wad;
|
||||
pub use wave_pm::WavePm;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use wedge::Wedge;
|
||||
@@ -1115,6 +1183,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Tsv",
|
||||
"Vzo",
|
||||
"MarketFacilitationIndex",
|
||||
"VolumeRsi",
|
||||
"Wad",
|
||||
"TwiggsMoneyFlow",
|
||||
"TradeVolumeIndex",
|
||||
"IntradayIntensity",
|
||||
"BetterVolume",
|
||||
"VolumeWeightedMacd",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1170,6 +1245,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"BodySizePct",
|
||||
"WickRatio",
|
||||
"HighLowRange",
|
||||
"JarqueBera",
|
||||
"RollingMinMaxScaler",
|
||||
"ShannonEntropy",
|
||||
"SampleEntropy",
|
||||
"KendallTau",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1194,6 +1274,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"EmpiricalModeDecomposition",
|
||||
"EhlersStochastic",
|
||||
"InstantaneousTrendline",
|
||||
"HighpassFilter",
|
||||
"Reflex",
|
||||
"Trendflex",
|
||||
"CorrelationTrendIndicator",
|
||||
"AdaptiveRsi",
|
||||
"UniversalOscillator",
|
||||
"AdaptiveCci",
|
||||
"BandpassFilter",
|
||||
"EvenBetterSinewave",
|
||||
"AutocorrelationPeriodogram",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1206,6 +1296,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"DemarkPivots",
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
"CentralPivotRange",
|
||||
"MurreyMathLines",
|
||||
"AndrewsPitchfork",
|
||||
"VolumeWeightedSr",
|
||||
"PivotReversal",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1223,6 +1318,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TdDifferential",
|
||||
"TdOpen",
|
||||
"TdRiskLevel",
|
||||
"TdCamouflage",
|
||||
"TdClop",
|
||||
"TdClopwin",
|
||||
"TdPropulsion",
|
||||
"TdTrap",
|
||||
"TdDWave",
|
||||
"TdMovingAverage",
|
||||
],
|
||||
),
|
||||
("Ichimoku & Charts", &["Ichimoku", "HeikinAshi"]),
|
||||
@@ -1474,6 +1576,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 440, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 474, "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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Reflex** — a near-zero-lag oscillator that measures how far the
|
||||
/// smoothed price has deviated from the straight line connecting its endpoints
|
||||
/// over the lookback.
|
||||
///
|
||||
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
|
||||
/// Feb 2020):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = SuperSmoother(price, period)
|
||||
/// slope = (Filt[period] − Filt[0]) / period (line over the window)
|
||||
/// sum = mean over i=1..period of ( Filt[0] + i·slope − Filt[i] )
|
||||
/// ms = 0.04·sum² + 0.96·ms[−1] (adaptive normaliser)
|
||||
/// Reflex = sum / sqrt(ms) (0 if ms == 0)
|
||||
/// ```
|
||||
///
|
||||
/// Reflex fits a straight line across the SuperSmoothed price over `period` bars
|
||||
/// and averages the deviation of the curve from that line. Because the line uses
|
||||
/// both endpoints, the measure has almost no lag — it crosses zero essentially at
|
||||
/// the cycle turns. The adaptive mean-square normaliser rescales the output to a
|
||||
/// roughly `±3` range regardless of price, so the same thresholds work on any
|
||||
/// instrument. Its sibling [`Trendflex`](crate::Trendflex) uses the deviation from
|
||||
/// the *current* value instead of the line, making it trend- rather than
|
||||
/// cycle-sensitive.
|
||||
///
|
||||
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
|
||||
/// is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Reflex};
|
||||
///
|
||||
/// let mut indicator = Reflex::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reflex {
|
||||
period: usize,
|
||||
smoother: SuperSmoother,
|
||||
filt: VecDeque<f64>,
|
||||
ms: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Reflex {
|
||||
/// Construct a Reflex with the given lookback `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
smoother: SuperSmoother::new(period)?,
|
||||
filt: VecDeque::with_capacity(period + 1),
|
||||
ms: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Reflex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.smoother.update(price)?;
|
||||
if self.filt.len() == self.period + 1 {
|
||||
self.filt.pop_front();
|
||||
}
|
||||
self.filt.push_back(filt);
|
||||
if self.filt.len() < self.period + 1 {
|
||||
return None;
|
||||
}
|
||||
// Newest at index `period`, oldest (period bars ago) at index 0.
|
||||
let newest = self.filt[self.period];
|
||||
let oldest = self.filt[0];
|
||||
let slope = (oldest - newest) / self.period as f64;
|
||||
let mut sum = 0.0;
|
||||
for i in 1..=self.period {
|
||||
sum += (newest + i as f64 * slope) - self.filt[self.period - i];
|
||||
}
|
||||
sum /= self.period as f64;
|
||||
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
|
||||
let reflex = if self.ms > 0.0 {
|
||||
sum / self.ms.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(reflex);
|
||||
Some(reflex)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.filt.clear();
|
||||
self.ms = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Reflex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Reflex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = Reflex::new(20).unwrap();
|
||||
assert_eq!(r.period(), 20);
|
||||
assert_eq!(r.warmup_period(), 21);
|
||||
assert_eq!(r.name(), "Reflex");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = Reflex::new(5).unwrap();
|
||||
let xs: Vec<f64> = (0..12)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
|
||||
.collect();
|
||||
let out = r.batch(&xs);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_zero() {
|
||||
// A flat price is exactly its own straight line -> zero deviation -> 0.
|
||||
let mut r = Reflex::new(10).unwrap();
|
||||
for v in r.batch(&[50.0; 100]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_oscillates_around_zero() {
|
||||
let mut r = Reflex::new(20).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = r.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut r = Reflex::new(10).unwrap();
|
||||
r.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = r.value();
|
||||
assert_eq!(r.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = Reflex::new(10).unwrap();
|
||||
r.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = Reflex::new(20).unwrap().batch(&xs);
|
||||
let mut b = Reflex::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Rolling Min-Max Scaler — normalises the latest value to `[0, 1]` over a window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Min-Max Scaler — maps the current value onto `[0, 1]` relative to the
|
||||
/// minimum and maximum of the trailing window.
|
||||
///
|
||||
/// ```text
|
||||
/// scaled = (x − min(window)) / (max(window) − min(window))
|
||||
/// ```
|
||||
///
|
||||
/// This is the streaming form of scikit-learn's `MinMaxScaler` applied over a
|
||||
/// sliding window: `0` means the value is the lowest in the window, `1` the
|
||||
/// highest, `0.5` the midpoint of the range. It is the engine behind oscillators
|
||||
/// like the Stochastic %K and a handy normaliser for feeding any indicator into a
|
||||
/// bounded model input. Because it rescales to the window's own range it is
|
||||
/// scale-free across instruments.
|
||||
///
|
||||
/// The output is in `[0, 1]`. A flat window (`max == min`) has no range to scale
|
||||
/// against and returns the neutral `0.5`. The first value lands after `period`
|
||||
/// inputs; each `update` scans the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RollingMinMaxScaler};
|
||||
///
|
||||
/// let mut indicator = RollingMinMaxScaler::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RollingMinMaxScaler {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl RollingMinMaxScaler {
|
||||
/// Construct a rolling min-max scaler over `period` values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 2` (a range needs two points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "min-max scaler needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RollingMinMaxScaler {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut min = f64::INFINITY;
|
||||
let mut max = f64::NEG_INFINITY;
|
||||
for &v in &self.window {
|
||||
min = min.min(v);
|
||||
max = max.max(v);
|
||||
}
|
||||
let range = max - min;
|
||||
let scaled = if range > 0.0 {
|
||||
(input - min) / range
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
self.last = Some(scaled);
|
||||
Some(scaled)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RollingMinMaxScaler"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(
|
||||
RollingMinMaxScaler::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
RollingMinMaxScaler::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(RollingMinMaxScaler::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = RollingMinMaxScaler::new(14).unwrap();
|
||||
assert_eq!(s.period(), 14);
|
||||
assert_eq!(s.warmup_period(), 14);
|
||||
assert_eq!(s.name(), "RollingMinMaxScaler");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let out = s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_in_window_is_one() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
// last value is the highest -> 1.0.
|
||||
let last = s
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_in_window_is_zero() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let last = s
|
||||
.batch(&[4.0, 3.0, 2.0, 1.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_is_half() {
|
||||
let mut s = RollingMinMaxScaler::new(3).unwrap();
|
||||
// window [0, 2, 1]: min 0, max 2, current 1 -> 0.5.
|
||||
let last = s
|
||||
.batch(&[0.0, 2.0, 1.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_half() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let last = s.batch(&[7.0; 8]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut s = RollingMinMaxScaler::new(14).unwrap();
|
||||
for v in s
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let ready = s
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(s.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
s.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = RollingMinMaxScaler::new(14).unwrap().batch(&xs);
|
||||
let mut b = RollingMinMaxScaler::new(14).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Sample Entropy (`SampEn`) — the regularity / predictability of a window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Population standard deviation of a slice (used for the matching tolerance).
|
||||
fn population_stddev(window: &[f64]) -> f64 {
|
||||
let n = window.len() as f64;
|
||||
let mean = window.iter().sum::<f64>() / n;
|
||||
let var = window.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
|
||||
var.max(0.0).sqrt()
|
||||
}
|
||||
|
||||
/// Whether two length-`len` templates starting at `i` and `j` match within the
|
||||
/// Chebyshev tolerance `tol`.
|
||||
fn templates_match(window: &[f64], i: usize, j: usize, len: usize, tol: f64) -> bool {
|
||||
for k in 0..len {
|
||||
if (window[i + k] - window[j + k]).abs() > tol {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Sample Entropy (`SampEn`) — Richman & Moorman's measure of how *regular* (i.e.
|
||||
/// predictable) a series is: the negative log conditional probability that two
|
||||
/// sub-sequences similar for `m` points stay similar at the next point.
|
||||
///
|
||||
/// ```text
|
||||
/// tol = r_factor · stddev(window)
|
||||
/// B = # template pairs of length m within tol (i < j)
|
||||
/// A = # template pairs of length m+1 within tol (i < j)
|
||||
/// `SampEn` = − ln(A / B)
|
||||
/// ```
|
||||
///
|
||||
/// Low `SampEn` means the window is **regular** — patterns of length `m` reliably
|
||||
/// extend to length `m + 1`, the fingerprint of a trending or cyclic market. High
|
||||
/// `SampEn` means the series is **irregular** — knowing the last `m` points tells
|
||||
/// you little about the next, the fingerprint of noise. Unlike the older
|
||||
/// approximate entropy (`ApEn`), `SampEn` excludes self-matches, so it is far less
|
||||
/// biased on short windows.
|
||||
///
|
||||
/// The tolerance is `r_factor` times the window's standard deviation, so the
|
||||
/// measure self-scales. A perfectly flat window (`stddev == 0`) is maximally
|
||||
/// regular and returns `0`. If no length-`m` pairs match, the entropy is
|
||||
/// undefined and `0` is returned; if length-`m` pairs match but none extend, the
|
||||
/// estimator falls back to treating the unseen count as one (`−ln(1/B) = ln(B)`).
|
||||
/// The first value lands after `period` inputs; each `update` is O(`period²`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, SampleEntropy};
|
||||
///
|
||||
/// let mut indicator = SampleEntropy::new(50, 2, 0.2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SampleEntropy {
|
||||
period: usize,
|
||||
emb_dim: usize,
|
||||
r_factor: f64,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl SampleEntropy {
|
||||
/// Construct a Sample Entropy over `period` values with embedding dimension
|
||||
/// `m` and tolerance factor `r_factor`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `m` is `0`,
|
||||
/// [`Error::InvalidPeriod`] if `period < m + 2` (no length-`m+1` template
|
||||
/// pairs otherwise), and [`Error::InvalidParameter`] if `r_factor` is not
|
||||
/// finite and positive.
|
||||
pub fn new(period: usize, m: usize, r_factor: f64) -> Result<Self> {
|
||||
if period == 0 || m == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < m + 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "sample entropy needs period >= m + 2",
|
||||
});
|
||||
}
|
||||
if !r_factor.is_finite() || r_factor <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "sample entropy r_factor must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
emb_dim: m,
|
||||
r_factor,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, m, r_factor)`.
|
||||
pub const fn params(&self) -> (usize, usize, f64) {
|
||||
(self.period, self.emb_dim, self.r_factor)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let window: Vec<f64> = self.window.iter().copied().collect();
|
||||
let std = population_stddev(&window);
|
||||
if std == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let tol = self.r_factor * std;
|
||||
let m = self.emb_dim;
|
||||
// Restrict both template lengths to the same index range so A and B share
|
||||
// their candidate pairs: there are `period − m` length-(m+1) templates.
|
||||
let count = self.period - m;
|
||||
let mut matches_m = 0u64;
|
||||
let mut matches_m1 = 0u64;
|
||||
for i in 0..count {
|
||||
for j in (i + 1)..count {
|
||||
if templates_match(&window, i, j, m, tol) {
|
||||
matches_m += 1;
|
||||
if templates_match(&window, i, j, m + 1, tol) {
|
||||
matches_m1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches_m == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
if matches_m1 == 0 {
|
||||
// No length-(m+1) matches: fall back to one unseen count.
|
||||
return (matches_m as f64).ln();
|
||||
}
|
||||
-((matches_m1 as f64) / (matches_m as f64)).ln()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SampleEntropy {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SampleEntropy"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(0, 2, 0.2),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(50, 0, 0.2),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(3, 2, 0.2),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(50, 2, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = SampleEntropy::new(50, 2, 0.2).unwrap();
|
||||
assert_eq!(s.params(), (50, 2, 0.2));
|
||||
assert_eq!(s.warmup_period(), 50);
|
||||
assert_eq!(s.name(), "SampleEntropy");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
|
||||
let xs: Vec<f64> = (0..14).map(|i| (f64::from(i) * 0.5).sin()).collect();
|
||||
let out = s.batch(&xs);
|
||||
for v in out.iter().take(9) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[9].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut s = SampleEntropy::new(20, 2, 0.2).unwrap();
|
||||
let last = s.batch(&[5.0; 30]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut s = SampleEntropy::new(40, 2, 0.2).unwrap();
|
||||
for v in s
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(v >= 0.0, "sample entropy must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_below_irregular() {
|
||||
// A smooth sine is far more regular (lower `SampEn`) than a chaotic
|
||||
// logistic-map series. (An *alternating* series would be periodic, hence
|
||||
// regular too -- chaos is what makes the window genuinely unpredictable.)
|
||||
let smooth: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.2).sin() * 5.0).collect();
|
||||
let mut x = 0.37_f64;
|
||||
let chaotic: Vec<f64> = (0..60)
|
||||
.map(|_| {
|
||||
x = 3.99 * x * (1.0 - x);
|
||||
x * 5.0
|
||||
})
|
||||
.collect();
|
||||
let s_smooth = SampleEntropy::new(50, 2, 0.2)
|
||||
.unwrap()
|
||||
.batch(&smooth)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
let s_chaotic = SampleEntropy::new(50, 2, 0.2)
|
||||
.unwrap()
|
||||
.batch(&chaotic)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
s_smooth <= s_chaotic,
|
||||
"smooth ({s_smooth}) should be <= chaotic ({s_chaotic})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
|
||||
let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
|
||||
let ready = s.batch(&xs).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(s.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
|
||||
let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
|
||||
s.batch(&xs);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = SampleEntropy::new(40, 2, 0.2).unwrap().batch(&xs);
|
||||
let mut b = SampleEntropy::new(40, 2, 0.2).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_no_m_plus_one_matches() {
|
||||
// `[1, 1, 1, 5]` with m = 2: the length-2 template `(1, 1)` repeats
|
||||
// (matches_m > 0) but no length-3 template repeats (matches_m1 == 0),
|
||||
// so SampEn takes the `ln(matches_m)` fallback branch.
|
||||
let xs = [1.0, 1.0, 1.0, 5.0];
|
||||
let v = SampleEntropy::new(4, 2, 0.2)
|
||||
.unwrap()
|
||||
.batch(&xs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(v.is_finite() && v >= 0.0, "got {v}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Shannon Entropy — the information content of a price window's distribution.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Shannon Entropy — the Shannon information entropy (in **bits**) of the
|
||||
/// distribution of values in a rolling window, after binning them into a fixed
|
||||
/// number of equal-width buckets.
|
||||
///
|
||||
/// ```text
|
||||
/// bucket each of the last `period` values into `bins` equal-width bins over
|
||||
/// [min, max] of the window
|
||||
/// p_i = count_i / period
|
||||
/// H = − Σ p_i · log2(p_i) (over non-empty bins)
|
||||
/// ```
|
||||
///
|
||||
/// Entropy measures how *spread out* and unpredictable the recent values are. A
|
||||
/// window concentrated in one bin (a flat or tightly-ranging market) has low
|
||||
/// entropy near `0`; a window whose values are spread evenly across all bins (a
|
||||
/// noisy, directionless market) approaches the maximum `log2(bins)`. Traders use
|
||||
/// it as a **regime filter**: low entropy favours trend/breakout strategies, high
|
||||
/// entropy favours mean-reversion or standing aside.
|
||||
///
|
||||
/// The output lies in `[0, log2(bins)]`. A degenerate window where every value is
|
||||
/// identical (`max == min`) returns `0`. The first value lands after `period`
|
||||
/// inputs; each `update` rebins the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, ShannonEntropy};
|
||||
///
|
||||
/// let mut indicator = ShannonEntropy::new(32, 8).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..64 {
|
||||
/// last = indicator.update((f64::from(i) * 0.7).sin() * 10.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShannonEntropy {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl ShannonEntropy {
|
||||
/// Construct a Shannon entropy over `period` values binned into `bins`
|
||||
/// buckets.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either argument is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `bins < 2` (entropy needs at least two bins).
|
||||
pub fn new(period: usize, bins: usize) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if bins < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Shannon entropy needs bins >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.period, self.bins)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ShannonEntropy {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut min = f64::INFINITY;
|
||||
let mut max = f64::NEG_INFINITY;
|
||||
for &v in &self.window {
|
||||
min = min.min(v);
|
||||
max = max.max(v);
|
||||
}
|
||||
if max <= min {
|
||||
// Degenerate window: all values identical -> zero entropy.
|
||||
self.last = Some(0.0);
|
||||
return Some(0.0);
|
||||
}
|
||||
let width = (max - min) / self.bins as f64;
|
||||
let mut counts = vec![0usize; self.bins];
|
||||
for &v in &self.window {
|
||||
// `(v - min) / width` is in [0, bins]; the cast truncates toward zero
|
||||
// (intended) and the value is non-negative, then clamped to the last
|
||||
// bin so the index is always valid.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let raw = ((v - min) / width) as usize;
|
||||
let idx = raw.min(self.bins - 1);
|
||||
counts[idx] += 1;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mut h = 0.0;
|
||||
for &count in &counts {
|
||||
if count > 0 {
|
||||
let p = count as f64 / n;
|
||||
h -= p * p.log2();
|
||||
}
|
||||
}
|
||||
self.last = Some(h);
|
||||
Some(h)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ShannonEntropy"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(ShannonEntropy::new(0, 8), Err(Error::PeriodZero)));
|
||||
assert!(matches!(ShannonEntropy::new(32, 0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
ShannonEntropy::new(32, 1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = ShannonEntropy::new(32, 8).unwrap();
|
||||
assert_eq!(e.params(), (32, 8));
|
||||
assert_eq!(e.warmup_period(), 32);
|
||||
assert_eq!(e.name(), "ShannonEntropy");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
let out = e.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut e = ShannonEntropy::new(8, 4).unwrap();
|
||||
let last = e.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_window_is_max_entropy() {
|
||||
// One value per bin -> uniform distribution -> H = log2(bins).
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
// Values 0,1,2,3 with min=0,max=3,width=0.75 -> bins 0,1,2,3.
|
||||
let last = e
|
||||
.batch(&[0.0, 1.0, 2.0, 3.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 2.0, epsilon = 1e-9); // log2(4) = 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut e = ShannonEntropy::new(32, 8).unwrap();
|
||||
let max_h = 8f64.log2();
|
||||
for v in e
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=max_h + 1e-9).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
let ready = e
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(e.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
e.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = ShannonEntropy::new(32, 8).unwrap().batch(&xs);
|
||||
let mut b = ShannonEntropy::new(32, 8).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,156 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD Camouflage — a hidden-strength/weakness 1-bar reversal pattern.
|
||||
//!
|
||||
//! TD Camouflage spots a bar that *looks* weak (or strong) on its close-to-close
|
||||
//! comparison but reveals the opposite intrabar, "camouflaging" a reversal.
|
||||
//!
|
||||
//! - **Buy signal** (`+1.0`): `close < close[-1]` (a lower close, looks bearish),
|
||||
//! yet `close > open` (it actually closed up on the bar) and `low < low[-1]`
|
||||
//! (it dipped to a new low and was bought back) — hidden accumulation.
|
||||
//! - **Sell signal** (`-1.0`): `close > close[-1]`, `close < open`, and
|
||||
//! `high > high[-1]` — hidden distribution.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! The one-bar lookback means the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TD Camouflage — 1-bar hidden-strength/weakness reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TdCamouflage {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TdCamouflage {
|
||||
/// Construct a new `TdCamouflage`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdCamouflage {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let v = if candle.close < prev.close && candle.close > candle.open && candle.low < prev.low
|
||||
{
|
||||
1.0
|
||||
} else if candle.close > prev.close && candle.close < candle.open && candle.high > prev.high
|
||||
{
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDCamouflage"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(open, high, low, close, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdCamouflage::new();
|
||||
assert_eq!(td.warmup_period(), 2);
|
||||
assert_eq!(td.name(), "TDCamouflage");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut td = TdCamouflage::new();
|
||||
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
|
||||
assert!(td.update(c(10.0, 11.0, 8.0, 9.5)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_camouflage_buy() {
|
||||
// prev close 10. Current: close 9.5 < 10 (lower close), close 9.5 > open 9.0,
|
||||
// low 7.0 < prev low 8.0 -> buy.
|
||||
let mut td = TdCamouflage::new();
|
||||
td.update(c(10.0, 11.0, 8.0, 10.0));
|
||||
assert_eq!(td.update(c(9.0, 10.0, 7.0, 9.5)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_camouflage_sell() {
|
||||
// prev close 10. Current: close 10.5 > 10, close 10.5 < open 11.0,
|
||||
// high 12.0 > prev high 11.0 -> sell.
|
||||
let mut td = TdCamouflage::new();
|
||||
td.update(c(10.0, 11.0, 8.0, 10.0));
|
||||
assert_eq!(td.update(c(11.0, 12.0, 10.0, 10.5)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pattern_is_zero() {
|
||||
let mut td = TdCamouflage::new();
|
||||
td.update(c(10.0, 11.0, 9.0, 10.0));
|
||||
assert_eq!(td.update(c(10.0, 11.5, 9.5, 11.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdCamouflage::new();
|
||||
td.update(c(10.0, 11.0, 9.0, 10.0));
|
||||
td.update(c(9.0, 10.0, 7.0, 9.5));
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.2)
|
||||
})
|
||||
.collect();
|
||||
let batch = TdCamouflage::new().batch(&candles);
|
||||
let mut b = TdCamouflage::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD Clop — a 2-bar open/close engulfing reversal.
|
||||
//!
|
||||
//! TD Clop ("CLose/OPen") fires when the current bar's open opens beyond **both**
|
||||
//! the prior bar's open and close, and its close finishes back beyond both — an
|
||||
//! open-gap that fully reverses, signalling a turn.
|
||||
//!
|
||||
//! - **Buy signal** (`+1.0`): `open < open[-1]` AND `open < close[-1]`
|
||||
//! (opens below the whole prior body) AND `close > open[-1]` AND
|
||||
//! `close > close[-1]` (closes above it).
|
||||
//! - **Sell signal** (`-1.0`): `open > open[-1]` AND `open > close[-1]` AND
|
||||
//! `close < open[-1]` AND `close < close[-1]`.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! The one-bar lookback means the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TD Clop — 2-bar open/close engulfing reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TdClop {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TdClop {
|
||||
/// Construct a new `TdClop`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdClop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let below_body = candle.open < prev.open && candle.open < prev.close;
|
||||
let above_body = candle.close > prev.open && candle.close > prev.close;
|
||||
let over_body = candle.open > prev.open && candle.open > prev.close;
|
||||
let under_body = candle.close < prev.open && candle.close < prev.close;
|
||||
let v = if below_body && above_body {
|
||||
1.0
|
||||
} else if over_body && under_body {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDClop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, close: f64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new_unchecked(open, high, low, close, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdClop::new();
|
||||
assert_eq!(td.warmup_period(), 2);
|
||||
assert_eq!(td.name(), "TDClop");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut td = TdClop::new();
|
||||
assert_eq!(td.update(c(10.0, 11.0)), Some(0.0));
|
||||
assert!(td.update(c(9.0, 12.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_clop_buy() {
|
||||
// prev body [10, 11]. Current open 9 < both, close 12 > both -> buy.
|
||||
let mut td = TdClop::new();
|
||||
td.update(c(10.0, 11.0));
|
||||
assert_eq!(td.update(c(9.0, 12.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_clop_sell() {
|
||||
// prev body [10, 11]. Current open 12 > both, close 9 < both -> sell.
|
||||
let mut td = TdClop::new();
|
||||
td.update(c(10.0, 11.0));
|
||||
assert_eq!(td.update(c(12.0, 9.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pattern_is_zero() {
|
||||
let mut td = TdClop::new();
|
||||
td.update(c(10.0, 11.0));
|
||||
assert_eq!(td.update(c(10.5, 11.5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdClop::new();
|
||||
td.update(c(10.0, 11.0));
|
||||
td.update(c(9.0, 12.0));
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.update(c(10.0, 11.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
|
||||
c(b, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = TdClop::new().batch(&candles);
|
||||
let mut b = TdClop::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD Clopwin — a 2-bar "close/open within" inside-body pattern.
|
||||
//!
|
||||
//! TD Clopwin ("CLose/OPen WInthIN") is the inside-body cousin of TD Clop: the
|
||||
//! current bar's open **and** close both sit within the prior bar's real body,
|
||||
//! marking a compression bar whose direction hints at the next move.
|
||||
//!
|
||||
//! - **Buy signal** (`+1.0`): current `open` and `close` are both inside the prior
|
||||
//! bar's body `[min(open,close)[-1], max(open,close)[-1]]` AND `close >= open`
|
||||
//! (a bullish inside bar).
|
||||
//! - **Sell signal** (`-1.0`): both inside the prior body AND `close < open`
|
||||
//! (a bearish inside bar).
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! The one-bar lookback means the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TD Clopwin — 2-bar inside-body compression pattern detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TdClopwin {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TdClopwin {
|
||||
/// Construct a new `TdClopwin`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdClopwin {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let body_low = prev.open.min(prev.close);
|
||||
let body_high = prev.open.max(prev.close);
|
||||
let open_in = candle.open >= body_low && candle.open <= body_high;
|
||||
let close_in = candle.close >= body_low && candle.close <= body_high;
|
||||
let v = if open_in && close_in {
|
||||
if candle.close >= candle.open {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDClopwin"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, close: f64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new_unchecked(open, high, low, close, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdClopwin::new();
|
||||
assert_eq!(td.warmup_period(), 2);
|
||||
assert_eq!(td.name(), "TDClopwin");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut td = TdClopwin::new();
|
||||
assert_eq!(td.update(c(10.0, 14.0)), Some(0.0));
|
||||
assert!(td.update(c(11.0, 13.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_inside_body_buy() {
|
||||
// prev body [10, 14]. Current open 11, close 13 both inside, close>open -> +1.
|
||||
let mut td = TdClopwin::new();
|
||||
td.update(c(10.0, 14.0));
|
||||
assert_eq!(td.update(c(11.0, 13.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_inside_body_sell() {
|
||||
// prev body [10, 14]. Current open 13, close 11 inside, close<open -> -1.
|
||||
let mut td = TdClopwin::new();
|
||||
td.update(c(10.0, 14.0));
|
||||
assert_eq!(td.update(c(13.0, 11.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_body_is_zero() {
|
||||
let mut td = TdClopwin::new();
|
||||
td.update(c(10.0, 14.0));
|
||||
// close 16 outside the prior body -> 0.
|
||||
assert_eq!(td.update(c(11.0, 16.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdClopwin::new();
|
||||
td.update(c(10.0, 14.0));
|
||||
td.update(c(11.0, 13.0));
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.update(c(10.0, 14.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
|
||||
c(b, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = TdClopwin::new().batch(&candles);
|
||||
let mut b = TdClopwin::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD D-Wave — a simplified Elliott-style swing-wave counter.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Tom DeMark **TD D-Wave** — a streaming wave counter that labels the market's
|
||||
/// swing sequence with an Elliott-style `1–5` impulse / `A–C` correction count.
|
||||
///
|
||||
/// TD D-Wave is DeMark's objective alternative to discretionary Elliott Wave
|
||||
/// counting. This streaming implementation detects alternating swing pivots with a
|
||||
/// symmetric fractal of half-width `strength`, and advances a counter through the
|
||||
/// eight-leg cycle each time a new swing leg is confirmed:
|
||||
///
|
||||
/// ```text
|
||||
/// legs: 1 → 2 → 3 → 4 → 5 → A(6) → B(7) → C(8) → 1 …
|
||||
/// output = current wave number, 1.0..8.0 (6/7/8 = corrective A/B/C)
|
||||
/// ```
|
||||
///
|
||||
/// The number tells you which wave of the cycle price is currently working on — a
|
||||
/// running map of impulse versus correction that updates as each swing confirms.
|
||||
/// This is a **simplified** swing-leg count (it does not enforce Elliott's price
|
||||
/// ratio and overlap rules); treat it as a structural guide, not a strict wave
|
||||
/// label.
|
||||
///
|
||||
/// Readiness is data-dependent: the first value appears once the first swing pivot
|
||||
/// confirms (`strength` bars after it forms). `warmup_period` returns the minimum
|
||||
/// bars to confirm one pivot. Each `update` is O(`strength`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TdDWave};
|
||||
///
|
||||
/// let mut indicator = TdDWave::new(2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// let _ = last;
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TdDWave {
|
||||
strength: usize,
|
||||
window: VecDeque<Candle>,
|
||||
last_is_high: Option<bool>,
|
||||
last_extreme: f64,
|
||||
wave: usize,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TdDWave {
|
||||
/// Construct a TD D-Wave with the given fractal `strength`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `strength == 0`.
|
||||
pub fn new(strength: usize) -> Result<Self> {
|
||||
if strength == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
strength,
|
||||
window: VecDeque::with_capacity(2 * strength + 1),
|
||||
last_is_high: None,
|
||||
last_extreme: 0.0,
|
||||
wave: 0,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured fractal strength.
|
||||
pub const fn strength(&self) -> usize {
|
||||
self.strength
|
||||
}
|
||||
|
||||
/// Current wave number if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
fn advance(&mut self, is_high: bool, price: f64) {
|
||||
match self.last_is_high {
|
||||
Some(prev) if prev == is_high => {
|
||||
// Same-direction extreme: extend the current leg if more extreme.
|
||||
let extends = if is_high {
|
||||
price > self.last_extreme
|
||||
} else {
|
||||
price < self.last_extreme
|
||||
};
|
||||
if extends {
|
||||
self.last_extreme = price;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// A new alternating leg: advance the wave counter (1..8 cycle).
|
||||
self.wave = self.wave % 8 + 1;
|
||||
self.last_is_high = Some(is_high);
|
||||
self.last_extreme = price;
|
||||
self.last_value = Some(self.wave as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdDWave {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let span = 2 * self.strength + 1;
|
||||
if self.window.len() == span {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() == span {
|
||||
let center = self.window[self.strength];
|
||||
let is_high = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == self.strength || c.high < center.high);
|
||||
let is_low = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == self.strength || c.low > center.low);
|
||||
if is_high && !is_low {
|
||||
self.advance(true, center.high);
|
||||
} else if is_low && !is_high {
|
||||
self.advance(false, center.low);
|
||||
}
|
||||
}
|
||||
self.last_value
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last_is_high = None;
|
||||
self.last_extreme = 0.0;
|
||||
self.wave = 0;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2 * self.strength + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDDWave"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
1_000.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn zigzag() -> Vec<Candle> {
|
||||
(0..200)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
c(base + 1.0, base - 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_strength() {
|
||||
assert!(matches!(TdDWave::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdDWave::new(2).unwrap();
|
||||
assert_eq!(td.strength(), 2);
|
||||
assert_eq!(td.warmup_period(), 5);
|
||||
assert_eq!(td.name(), "TDDWave");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_waves_on_swings() {
|
||||
let mut td = TdDWave::new(2).unwrap();
|
||||
let out = td.batch(&zigzag());
|
||||
assert!(out.iter().any(Option::is_some));
|
||||
assert!(td.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_pivots_extend_one_leg() {
|
||||
// Strictly decreasing lows mean no bar is ever a low pivot, so the
|
||||
// confirmed pivots are all highs. Consecutive same-direction highs
|
||||
// exercise the `extends` branch (true at 30 > 20, false at 25 < 30)
|
||||
// without ever advancing the wave past leg 1.
|
||||
let mut td = TdDWave::new(1).unwrap();
|
||||
let bars = [
|
||||
(10.0, 100.0),
|
||||
(20.0, 99.0),
|
||||
(12.0, 98.0),
|
||||
(30.0, 97.0),
|
||||
(15.0, 96.0),
|
||||
(25.0, 95.0),
|
||||
(14.0, 94.0),
|
||||
(14.0, 93.0),
|
||||
];
|
||||
let vals: Vec<f64> = bars
|
||||
.iter()
|
||||
.filter_map(|&(high, low)| td.update(c(high, low)))
|
||||
.collect();
|
||||
assert!(!vals.is_empty());
|
||||
assert!(vals.iter().all(|&v| v == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_low_pivots_extend_one_leg() {
|
||||
// Mirror of the high-pivot case: strictly increasing highs mean no bar
|
||||
// is ever a high pivot, so the confirmed pivots are all lows. The
|
||||
// `extends` else-branch fires (true at 2 < 5, false at 4 > 2).
|
||||
let mut td = TdDWave::new(1).unwrap();
|
||||
let bars = [
|
||||
(100.0, 10.0),
|
||||
(101.0, 5.0),
|
||||
(102.0, 8.0),
|
||||
(103.0, 2.0),
|
||||
(104.0, 6.0),
|
||||
(105.0, 4.0),
|
||||
(106.0, 7.0),
|
||||
(107.0, 7.0),
|
||||
];
|
||||
let vals: Vec<f64> = bars
|
||||
.iter()
|
||||
.filter_map(|&(high, low)| td.update(c(high, low)))
|
||||
.collect();
|
||||
assert!(!vals.is_empty());
|
||||
assert!(vals.iter().all(|&v| v == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave_stays_in_one_to_eight() {
|
||||
let mut td = TdDWave::new(2).unwrap();
|
||||
for v in td.batch(&zigzag()).into_iter().flatten() {
|
||||
assert!((1.0..=8.0).contains(&v), "wave out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_never_counts() {
|
||||
// A perfectly flat series has no distinct swing highs/lows.
|
||||
let mut td = TdDWave::new(2).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|_| c(100.0, 100.0)).collect();
|
||||
assert!(td.batch(&candles).iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdDWave::new(2).unwrap();
|
||||
td.batch(&zigzag());
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = zigzag();
|
||||
let batch = TdDWave::new(2).unwrap().batch(&candles);
|
||||
let mut b = TdDWave::new(2).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD Moving Averages — the ST1 (fast) and ST2 (slow) trend ribbon.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`TdMovingAverage`]: the fast (`st1`) and slow (`st2`) moving-average
|
||||
/// lines.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TdMovingAverageOutput {
|
||||
/// ST1 — the fast (short) moving average.
|
||||
pub st1: f64,
|
||||
/// ST2 — the slow (long) moving average.
|
||||
pub st2: f64,
|
||||
}
|
||||
|
||||
/// Tom DeMark **TD Moving Averages** — a two-line trend ribbon (ST1 fast, ST2
|
||||
/// slow) computed on the median price, whose relationship defines the trend.
|
||||
///
|
||||
/// ```text
|
||||
/// price = (high + low) / 2 (median price)
|
||||
/// st1 = SMA(price, period_st1) (fast / "Sequential Trend 1")
|
||||
/// st2 = SMA(price, period_st2) (slow / "Sequential Trend 2")
|
||||
/// ```
|
||||
///
|
||||
/// DeMark's moving-average pair frames the trend objectively: when `st1` is above
|
||||
/// `st2` the trend is up, below it down, and the cross marks the change. Using the
|
||||
/// **median price** rather than the close de-emphasises closing noise. This is a
|
||||
/// streaming dual-SMA implementation of the ST1/ST2 ribbon; read the lines and
|
||||
/// their crossover exactly as a fast/slow moving-average system.
|
||||
///
|
||||
/// `period_st1` must be strictly smaller than `period_st2`. The first value lands
|
||||
/// once the slow average is seeded (`period_st2` inputs). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TdMovingAverage};
|
||||
///
|
||||
/// let mut indicator = TdMovingAverage::new(5, 13).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TdMovingAverage {
|
||||
st1: Sma,
|
||||
st2: Sma,
|
||||
period_st1: usize,
|
||||
period_st2: usize,
|
||||
last: Option<TdMovingAverageOutput>,
|
||||
}
|
||||
|
||||
impl TdMovingAverage {
|
||||
/// Construct TD Moving Averages with the given fast and slow periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, and
|
||||
/// [`Error::InvalidPeriod`] if `period_st1 >= period_st2`.
|
||||
pub fn new(period_st1: usize, period_st2: usize) -> Result<Self> {
|
||||
if period_st1 == 0 || period_st2 == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period_st1 >= period_st2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "TD moving average ST1 period must be strictly less than ST2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
st1: Sma::new(period_st1)?,
|
||||
st2: Sma::new(period_st2)?,
|
||||
period_st1,
|
||||
period_st2,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period_st1, period_st2)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.period_st1, self.period_st2)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<TdMovingAverageOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdMovingAverage {
|
||||
type Input = Candle;
|
||||
type Output = TdMovingAverageOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<TdMovingAverageOutput> {
|
||||
let price = candle.median_price();
|
||||
let fast = self.st1.update(price);
|
||||
let slow = self.st2.update(price);
|
||||
if let (Some(st1), Some(st2)) = (fast, slow) {
|
||||
let out = TdMovingAverageOutput { st1, st2 };
|
||||
self.last = Some(out);
|
||||
return Some(out);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.st1.reset();
|
||||
self.st2.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period_st2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDMovingAverage"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(median: f64) -> Candle {
|
||||
Candle::new_unchecked(median, median + 1.0, median - 1.0, median, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
TdMovingAverage::new(0, 13),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
TdMovingAverage::new(13, 5),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TdMovingAverage::new(5, 5),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdMovingAverage::new(5, 13).unwrap();
|
||||
assert_eq!(td.periods(), (5, 13));
|
||||
assert_eq!(td.warmup_period(), 13);
|
||||
assert_eq!(td.name(), "TDMovingAverage");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut td = TdMovingAverage::new(2, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..8).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let out = td.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_leads_slow_in_uptrend() {
|
||||
let mut td = TdMovingAverage::new(3, 7).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let out = td.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(out.st1 > out.st2, "fast MA should lead in an uptrend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_below_slow_in_downtrend() {
|
||||
let mut td = TdMovingAverage::new(3, 7).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| c(200.0 - f64::from(i))).collect();
|
||||
let out = td.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(out.st1 < out.st2, "fast MA should trail in a downtrend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_series_equal_lines() {
|
||||
let mut td = TdMovingAverage::new(2, 4).unwrap();
|
||||
let out = td
|
||||
.batch(&[c(50.0); 10])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.st1, 50.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.st2, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdMovingAverage::new(2, 4).unwrap();
|
||||
td.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
assert_eq!(td.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = TdMovingAverage::new(5, 13).unwrap().batch(&candles);
|
||||
let mut b = TdMovingAverage::new(5, 13).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD Propulsion — a 2-bar trend-continuation thrust signal.
|
||||
//!
|
||||
//! TD Propulsion qualifies a continuation thrust: the bar opens on the trend side
|
||||
//! of the prior close and then closes beyond the prior bar's extreme, "propelling"
|
||||
//! the move forward.
|
||||
//!
|
||||
//! - **Propulsion up** (`+1.0`): `open >= close[-1]` (opens at or above the prior
|
||||
//! close) AND `close > high[-1]` (closes above the prior high).
|
||||
//! - **Propulsion down** (`-1.0`): `open <= close[-1]` AND `close < low[-1]`.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! The one-bar lookback means the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TD Propulsion — 2-bar trend-continuation thrust detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TdPropulsion {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TdPropulsion {
|
||||
/// Construct a new `TdPropulsion`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdPropulsion {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let v = if candle.open >= prev.close && candle.close > prev.high {
|
||||
1.0
|
||||
} else if candle.open <= prev.close && candle.close < prev.low {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDPropulsion"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(open, high, low, close, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdPropulsion::new();
|
||||
assert_eq!(td.warmup_period(), 2);
|
||||
assert_eq!(td.name(), "TDPropulsion");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut td = TdPropulsion::new();
|
||||
assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
|
||||
assert!(td.update(c(10.5, 12.0, 10.0, 11.5)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propulsion_up() {
|
||||
// prev close 10, high 11. Current open 10.5 >= 10, close 11.5 > 11 -> +1.
|
||||
let mut td = TdPropulsion::new();
|
||||
td.update(c(9.5, 11.0, 9.0, 10.0));
|
||||
assert_eq!(td.update(c(10.5, 12.0, 10.0, 11.5)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propulsion_down() {
|
||||
// prev close 10, low 9. Current open 9.5 <= 10, close 8.5 < 9 -> -1.
|
||||
let mut td = TdPropulsion::new();
|
||||
td.update(c(10.5, 11.0, 9.0, 10.0));
|
||||
assert_eq!(td.update(c(9.5, 10.0, 8.0, 8.5)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_thrust_is_zero() {
|
||||
let mut td = TdPropulsion::new();
|
||||
td.update(c(9.5, 11.0, 9.0, 10.0));
|
||||
// close 10.5 not above prior high 11 -> 0.
|
||||
assert_eq!(td.update(c(10.5, 10.8, 10.0, 10.5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdPropulsion::new();
|
||||
td.update(c(9.5, 11.0, 9.0, 10.0));
|
||||
td.update(c(10.5, 12.0, 10.0, 11.5));
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.update(c(9.5, 11.0, 9.0, 10.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = TdPropulsion::new().batch(&candles);
|
||||
let mut b = TdPropulsion::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout.
|
||||
//!
|
||||
//! A TD Trap forms when one bar is an **inside bar** (its high below and low above
|
||||
//! the prior bar's), coiling the market; the next bar that closes beyond the trap
|
||||
//! bar's high or low triggers the directional signal.
|
||||
//!
|
||||
//! - **Buy signal** (`+1.0`): the prior bar was an inside bar and the current
|
||||
//! `close` is above that inside bar's `high`.
|
||||
//! - **Sell signal** (`-1.0`): the prior bar was an inside bar and the current
|
||||
//! `close` is below that inside bar's `low`.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! The two-bar lookback (one to set the inside bar, one before it) means the first
|
||||
//! value lands on the third candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TD Trap — inside-bar breakout signal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TdTrap {
|
||||
prev1: Option<Candle>,
|
||||
prev2: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TdTrap {
|
||||
/// Construct a new `TdTrap`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TdTrap {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let (Some(trap), Some(before)) = (self.prev1, self.prev2) else {
|
||||
// Not enough history yet: emit a neutral 0.0 while seeding.
|
||||
self.prev2 = self.prev1;
|
||||
self.prev1 = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let is_inside = trap.high < before.high && trap.low > before.low;
|
||||
let v = if is_inside && candle.close > trap.high {
|
||||
1.0
|
||||
} else if is_inside && candle.close < trap.low {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev2 = self.prev1;
|
||||
self.prev1 = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev1 = None;
|
||||
self.prev2 = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TDTrap"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let td = TdTrap::new();
|
||||
assert_eq!(td.warmup_period(), 3);
|
||||
assert_eq!(td.name(), "TDTrap");
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_seed_without_signal() {
|
||||
let mut td = TdTrap::new();
|
||||
assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
|
||||
assert_eq!(td.update(c(108.0, 95.0, 102.0)), Some(0.0));
|
||||
assert!(td.update(c(112.0, 100.0, 110.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_then_breakout_up_buys() {
|
||||
// bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1.
|
||||
let mut td = TdTrap::new();
|
||||
td.update(c(110.0, 90.0, 100.0));
|
||||
td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90)
|
||||
assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_then_breakdown_sells() {
|
||||
let mut td = TdTrap::new();
|
||||
td.update(c(110.0, 90.0, 100.0));
|
||||
td.update(c(108.0, 95.0, 102.0)); // inside bar
|
||||
assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_inside_bar_is_zero() {
|
||||
let mut td = TdTrap::new();
|
||||
td.update(c(110.0, 90.0, 100.0));
|
||||
td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside
|
||||
assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_but_no_breakout_is_zero() {
|
||||
let mut td = TdTrap::new();
|
||||
td.update(c(110.0, 90.0, 100.0));
|
||||
td.update(c(108.0, 95.0, 102.0)); // inside bar
|
||||
assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut td = TdTrap::new();
|
||||
td.update(c(110.0, 90.0, 100.0));
|
||||
td.update(c(108.0, 95.0, 102.0));
|
||||
td.update(c(112.0, 100.0, 109.0));
|
||||
assert!(td.is_ready());
|
||||
td.reset();
|
||||
assert!(!td.is_ready());
|
||||
assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
|
||||
c(b + 2.0, b - 2.0, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = TdTrap::new().batch(&candles);
|
||||
let mut b = TdTrap::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trade Volume Index — a cumulative line that adds volume while price ticks up
|
||||
/// and subtracts it while price ticks down, where "up" and "down" are decided by
|
||||
/// a **minimum tick value** rather than any change.
|
||||
///
|
||||
/// ```text
|
||||
/// change = close − prev_close
|
||||
/// if change > min_tick: direction = +1
|
||||
/// if change < −min_tick: direction = −1
|
||||
/// else: direction unchanged (price is "churning")
|
||||
/// TVI_t = TVI_{t−1} + direction * volume
|
||||
/// ```
|
||||
///
|
||||
/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
|
||||
/// flip the accumulation direction, so a price drifting within the spread keeps
|
||||
/// adding volume in the last established direction instead of whipsawing. This is
|
||||
/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
|
||||
/// and applied to close-to-close moves. Like all cumulative lines, only its slope
|
||||
/// and divergences against price carry meaning — the absolute level is arbitrary.
|
||||
///
|
||||
/// The first candle seeds the reference close and emits nothing; thereafter each
|
||||
/// bar emits the running total. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
|
||||
///
|
||||
/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let close = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TradeVolumeIndex {
|
||||
min_tick: f64,
|
||||
prev_close: Option<f64>,
|
||||
direction: f64,
|
||||
tvi: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TradeVolumeIndex {
|
||||
/// Construct a new Trade Volume Index with the given minimum tick value.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
|
||||
/// negative. A `min_tick` of `0` is allowed and makes every non-zero move
|
||||
/// flip the direction.
|
||||
pub fn new(min_tick: f64) -> Result<Self> {
|
||||
if !min_tick.is_finite() || min_tick < 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "trade volume index min_tick must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_tick,
|
||||
prev_close: None,
|
||||
direction: 0.0,
|
||||
tvi: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured minimum tick value.
|
||||
pub const fn min_tick(&self) -> f64 {
|
||||
self.min_tick
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TradeVolumeIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let change = candle.close - prev_close;
|
||||
if change > self.min_tick {
|
||||
self.direction = 1.0;
|
||||
} else if change < -self.min_tick {
|
||||
self.direction = -1.0;
|
||||
}
|
||||
// Otherwise the direction is held from the previous bar (or 0 before the
|
||||
// first decisive move), so a churning price keeps its last lean.
|
||||
self.tvi += self.direction * candle.volume;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.last = Some(self.tvi);
|
||||
Some(self.tvi)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.direction = 0.0;
|
||||
self.tvi = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TradeVolumeIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_min_tick() {
|
||||
assert!(matches!(
|
||||
TradeVolumeIndex::new(-1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TradeVolumeIndex::new(f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(TradeVolumeIndex::new(0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tvi = TradeVolumeIndex::new(0.25).unwrap();
|
||||
assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
|
||||
assert_eq!(tvi.warmup_period(), 2);
|
||||
assert_eq!(tvi.name(), "TradeVolumeIndex");
|
||||
assert!(!tvi.is_ready());
|
||||
assert_eq!(tvi.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_output() {
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
|
||||
assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_accumulates_volume() {
|
||||
// Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
let candles = [
|
||||
candle(100.0, 1_000.0), // seed
|
||||
candle(101.0, 500.0), // +1 -> +500
|
||||
candle(102.0, 300.0), // +1 -> +300
|
||||
];
|
||||
let out = tvi.batch(&candles);
|
||||
assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_move_holds_last_direction() {
|
||||
// After an up-move, a sub-tick wobble keeps adding in the up direction.
|
||||
let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
|
||||
let candles = [
|
||||
candle(100.0, 1_000.0), // seed
|
||||
candle(102.0, 400.0), // +2 > tick -> dir +1, +400
|
||||
candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100
|
||||
];
|
||||
let out = tvi.batch(&candles);
|
||||
assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_distributes_volume() {
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
let candles = [
|
||||
candle(100.0, 1_000.0),
|
||||
candle(99.0, 200.0), // -1 -> -200
|
||||
candle(98.0, 300.0), // -1 -> -300
|
||||
];
|
||||
let out = tvi.batch(&candles);
|
||||
assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
|
||||
tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
|
||||
assert!(tvi.is_ready());
|
||||
tvi.reset();
|
||||
assert!(!tvi.is_ready());
|
||||
assert_eq!(tvi.value(), None);
|
||||
assert_eq!(tvi.update(candle(100.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
|
||||
let mut b = TradeVolumeIndex::new(0.5).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Ehlers Trendflex — a trend-sensitive sibling of Reflex.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Trendflex** — the trend-sensitive companion to
|
||||
/// [`Reflex`](crate::Reflex): it averages how far the SuperSmoothed price sits
|
||||
/// above or below its values over the lookback, then self-normalises.
|
||||
///
|
||||
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
|
||||
/// Feb 2020):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = SuperSmoother(price, period)
|
||||
/// sum = mean over i=1..period of ( Filt[0] − Filt[i] )
|
||||
/// ms = 0.04·sum² + 0.96·ms[−1] (adaptive normaliser)
|
||||
/// Trendflex = sum / sqrt(ms) (0 if ms == 0)
|
||||
/// ```
|
||||
///
|
||||
/// Where Reflex measures deviation from the straight *line* across the window
|
||||
/// (cycle sensitive, near zero lag), Trendflex measures deviation from the
|
||||
/// window's *values* (trend sensitive). It stays pinned to one side of zero
|
||||
/// during a trend and oscillates through zero in a range, so it doubles as a
|
||||
/// trend/range gauge. The adaptive mean-square normaliser keeps the output near a
|
||||
/// `±3` band on any instrument.
|
||||
///
|
||||
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
|
||||
/// is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Trendflex};
|
||||
///
|
||||
/// let mut indicator = Trendflex::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Trendflex {
|
||||
period: usize,
|
||||
smoother: SuperSmoother,
|
||||
filt: VecDeque<f64>,
|
||||
ms: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Trendflex {
|
||||
/// Construct a Trendflex with the given lookback `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
smoother: SuperSmoother::new(period)?,
|
||||
filt: VecDeque::with_capacity(period + 1),
|
||||
ms: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Trendflex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.smoother.update(price)?;
|
||||
if self.filt.len() == self.period + 1 {
|
||||
self.filt.pop_front();
|
||||
}
|
||||
self.filt.push_back(filt);
|
||||
if self.filt.len() < self.period + 1 {
|
||||
return None;
|
||||
}
|
||||
let newest = self.filt[self.period];
|
||||
let mut sum = 0.0;
|
||||
for i in 1..=self.period {
|
||||
sum += newest - self.filt[self.period - i];
|
||||
}
|
||||
sum /= self.period as f64;
|
||||
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
|
||||
let trendflex = if self.ms > 0.0 {
|
||||
sum / self.ms.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(trendflex);
|
||||
Some(trendflex)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.filt.clear();
|
||||
self.ms = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Trendflex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Trendflex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = Trendflex::new(20).unwrap();
|
||||
assert_eq!(t.period(), 20);
|
||||
assert_eq!(t.warmup_period(), 21);
|
||||
assert_eq!(t.name(), "Trendflex");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut t = Trendflex::new(5).unwrap();
|
||||
let xs: Vec<f64> = (0..12).map(f64::from).collect();
|
||||
let out = t.batch(&xs);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_zero() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
for v in t.batch(&[50.0; 100]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
// A steady rise keeps the current filtered value above its past values.
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
let out: Vec<f64> = t
|
||||
.batch(&(0..200).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(100)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v > 0.0, "uptrend should be positive, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
let out: Vec<f64> = t
|
||||
.batch(&(0..200).map(|i| 200.0 - f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(100)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v < 0.0, "downtrend should be negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = t.value();
|
||||
assert_eq!(t.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = Trendflex::new(20).unwrap().batch(&xs);
|
||||
let mut b = Trendflex::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
|
||||
/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
|
||||
///
|
||||
/// ```text
|
||||
/// TRH = max(high, prev_close) (true high)
|
||||
/// TRL = min(low, prev_close) (true low)
|
||||
/// ad = volume * (2*close − TRH − TRL) / (TRH − TRL) (0 if TRH == TRL)
|
||||
/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period)
|
||||
/// ```
|
||||
///
|
||||
/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
|
||||
/// the bar's raw high/low with the *true* high/low (folding in the prior close so
|
||||
/// gaps count), and it smooths the accumulated money flow and the volume with a
|
||||
/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
|
||||
/// reacts faster and never jumps when a large bar drops out of a window. The
|
||||
/// output is bounded in roughly `[−1, +1]`: positive means buying pressure
|
||||
/// (closes biased toward the true high), negative means selling pressure.
|
||||
///
|
||||
/// The first candle seeds the reference close; the next `period` bars seed both
|
||||
/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
|
||||
/// of zero volume makes the denominator average `0`, in which case the oscillator
|
||||
/// reports `0` rather than `0 / 0`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
|
||||
///
|
||||
/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TwiggsMoneyFlow {
|
||||
period: usize,
|
||||
prev_close: Option<f64>,
|
||||
seed_ad: f64,
|
||||
seed_vol: f64,
|
||||
seed_count: usize,
|
||||
ad_ema: Option<f64>,
|
||||
vol_ema: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TwiggsMoneyFlow {
|
||||
/// Construct a new Twiggs Money Flow with the given smoothing `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_close: None,
|
||||
seed_ad: 0.0,
|
||||
seed_vol: 0.0,
|
||||
seed_count: 0,
|
||||
ad_ema: None,
|
||||
vol_ema: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
|
||||
if vol_ema == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
ad_ema / vol_ema
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TwiggsMoneyFlow {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let trh = candle.high.max(prev_close);
|
||||
let trl = candle.low.min(prev_close);
|
||||
let range = trh - trl;
|
||||
let ad = if range > 0.0 {
|
||||
candle.volume * (2.0 * candle.close - trh - trl) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev_close = Some(candle.close);
|
||||
|
||||
if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
|
||||
let n = self.period as f64;
|
||||
let new_ad = ad_ema + (ad - ad_ema) / n;
|
||||
let new_vol = vol_ema + (candle.volume - vol_ema) / n;
|
||||
self.ad_ema = Some(new_ad);
|
||||
self.vol_ema = Some(new_vol);
|
||||
let v = Self::ratio(new_ad, new_vol);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_ad += ad;
|
||||
self.seed_vol += candle.volume;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let n = self.period as f64;
|
||||
let ad_ema = self.seed_ad / n;
|
||||
let vol_ema = self.seed_vol / n;
|
||||
self.ad_ema = Some(ad_ema);
|
||||
self.vol_ema = Some(vol_ema);
|
||||
let v = Self::ratio(ad_ema, vol_ema);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.seed_ad = 0.0;
|
||||
self.seed_vol = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.ad_ema = None;
|
||||
self.vol_ema = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TwiggsMoneyFlow"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_bars_drive_tmf_to_zero() {
|
||||
// A flat bar (high == low == close == prior close) gives a zero two-bar
|
||||
// range, so the accumulation term falls back to 0.0 and TMF settles at
|
||||
// zero. Exercises the `range == 0` guard.
|
||||
let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
|
||||
let flat: Vec<Candle> = (0..6)
|
||||
.map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
|
||||
.collect();
|
||||
let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tmf = TwiggsMoneyFlow::new(21).unwrap();
|
||||
assert_eq!(tmf.period(), 21);
|
||||
assert_eq!(tmf.warmup_period(), 22);
|
||||
assert_eq!(tmf.name(), "TwiggsMoneyFlow");
|
||||
assert!(!tmf.is_ready());
|
||||
assert_eq!(tmf.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..8)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base, 1_000.0)
|
||||
})
|
||||
.collect();
|
||||
let out = tmf.batch(&candles);
|
||||
// warmup_period == period + 1 == 4: first emission at index 3.
|
||||
for o in out.iter().take(3) {
|
||||
assert!(o.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_at_true_high_is_positive() {
|
||||
// Every bar closes at its high -> strong buying pressure -> TMF -> +1.
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
// open=low=base-1, high=close=base+1 -> closes at the top.
|
||||
Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
|
||||
})
|
||||
.collect();
|
||||
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.9,
|
||||
"closing at the high should drive TMF near +1, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_at_true_low_is_negative() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 - f64::from(i);
|
||||
// closes at the low.
|
||||
Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
|
||||
})
|
||||
.collect();
|
||||
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < -0.5,
|
||||
"closing at the low should drive TMF negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_yields_zero() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base, 0.0)
|
||||
})
|
||||
.collect();
|
||||
for v in tmf.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
|
||||
})
|
||||
.collect();
|
||||
for v in tmf.batch(&candles).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..12)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base, 1_000.0)
|
||||
})
|
||||
.collect();
|
||||
tmf.batch(&candles);
|
||||
assert!(tmf.is_ready());
|
||||
tmf.reset();
|
||||
assert!(!tmf.is_ready());
|
||||
assert_eq!(tmf.value(), None);
|
||||
assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
|
||||
let mut b = TwiggsMoneyFlow::new(21).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Universal Oscillator** — a cycle oscillator that whitens the price
|
||||
/// series, SuperSmooths it, then normalises with an automatic gain control (AGC)
|
||||
/// to swing in `[−1, +1]`.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// WhiteNoise = (price_t − price_{t−2}) / 2 (flat-spectrum prewhitening)
|
||||
/// Filt = SuperSmoother(WhiteNoise, period)
|
||||
/// Peak = max(|Filt|, 0.991 · Peak_{t−1}) (decaying peak / AGC)
|
||||
/// Universal = Filt / Peak (0 if Peak == 0)
|
||||
/// ```
|
||||
///
|
||||
/// "Whitening" the input (a two-bar difference) flattens its power spectrum so the
|
||||
/// SuperSmoother responds equally to all cycles rather than being dominated by the
|
||||
/// trend. The automatic gain control divides by a slowly-decaying running peak, so
|
||||
/// the output is amplitude-normalised to `[−1, +1]` and behaves consistently
|
||||
/// across instruments and volatility regimes — hence "universal". Read it like any
|
||||
/// bounded oscillator: turns near the rails flag cycle extremes, zero-crossings
|
||||
/// flag cycle direction changes.
|
||||
///
|
||||
/// The first value lands once a two-bar difference exists (`warmup_period == 3`).
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, UniversalOscillator};
|
||||
///
|
||||
/// let mut indicator = UniversalOscillator::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UniversalOscillator {
|
||||
period: usize,
|
||||
smoother: SuperSmoother,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
peak: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl UniversalOscillator {
|
||||
/// Construct a Universal Oscillator with the given SuperSmoother `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
smoother: SuperSmoother::new(period)?,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
peak: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for UniversalOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let Some(p2) = self.prev_price_2 else {
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
return None;
|
||||
};
|
||||
let white_noise = (price - p2) / 2.0;
|
||||
if !white_noise.is_finite() {
|
||||
// `price - p2` can overflow to +/-inf even when both are finite;
|
||||
// skip the bar rather than feeding a non-finite value downstream.
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
return self.last;
|
||||
}
|
||||
let filt = self
|
||||
.smoother
|
||||
.update(white_noise)
|
||||
.expect("supersmoother emits");
|
||||
self.peak = filt.abs().max(0.991 * self.peak);
|
||||
let universal = if self.peak > 0.0 {
|
||||
(filt / self.peak).clamp(-1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.last = Some(universal);
|
||||
Some(universal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.peak = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"UniversalOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
UniversalOscillator::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let u = UniversalOscillator::new(20).unwrap();
|
||||
assert_eq!(u.period(), 20);
|
||||
assert_eq!(u.warmup_period(), 3);
|
||||
assert_eq!(u.name(), "UniversalOscillator");
|
||||
assert!(!u.is_ready());
|
||||
assert_eq!(u.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
let out = u.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_zero() {
|
||||
// A flat input whitens to zero -> output 0.
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
for v in u.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert!(v.abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in u.batch(&xs).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_swings_both_signs() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = u.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
u.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = u.value();
|
||||
assert_eq!(u.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
u.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(u.is_ready());
|
||||
u.reset();
|
||||
assert!(!u.is_ready());
|
||||
assert_eq!(u.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = UniversalOscillator::new(20).unwrap().batch(&xs);
|
||||
let mut b = UniversalOscillator::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_white_noise_is_skipped() {
|
||||
// `price - p2` can overflow to infinity even when both prices are
|
||||
// finite; the non-finite white-noise term must be skipped, not fed to
|
||||
// the smoother (which would otherwise yield `None` on the first bar).
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
assert_eq!(u.update(-1e308), None);
|
||||
assert_eq!(u.update(0.0), None);
|
||||
// (1e308 - (-1e308)) overflows to +inf -> white_noise non-finite.
|
||||
assert_eq!(u.update(1e308), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Volume RSI — Wilder's RSI applied to the volume stream.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Volume RSI — the Relative Strength Index computed on **volume** changes
|
||||
/// instead of price changes.
|
||||
///
|
||||
/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
|
||||
/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
|
||||
/// change in volume:
|
||||
///
|
||||
/// ```text
|
||||
/// change_t = volume_t − volume_{t−1}
|
||||
/// gain = max(change, 0), loss = max(−change, 0)
|
||||
/// avg_gain, avg_loss = Wilder-smoothed over `period`
|
||||
/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// Readings above `50` mean volume is expanding (more was added than removed over
|
||||
/// the smoothing window) and tend to confirm the prevailing move; readings below
|
||||
/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
|
||||
/// of unchanged volume drives both averages to `0` and the indicator reports the
|
||||
/// neutral `50` rather than an undefined `0 / 0`.
|
||||
///
|
||||
/// Only the candle's **volume** is used. The first bar sets the previous volume,
|
||||
/// then `period` changes seed Wilder's averages, so the first value lands after
|
||||
/// `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VolumeRsi};
|
||||
///
|
||||
/// let mut indicator = VolumeRsi::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
|
||||
/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolumeRsi {
|
||||
period: usize,
|
||||
prev_volume: Option<f64>,
|
||||
seed_gains: f64,
|
||||
seed_losses: f64,
|
||||
seed_count: usize,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl VolumeRsi {
|
||||
/// Construct a Volume RSI with the given Wilder smoothing `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_volume: None,
|
||||
seed_gains: 0.0,
|
||||
seed_losses: 0.0,
|
||||
seed_count: 0,
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolumeRsi {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let volume = candle.volume;
|
||||
let Some(prev) = self.prev_volume else {
|
||||
self.prev_volume = Some(volume);
|
||||
return None;
|
||||
};
|
||||
let change = volume - prev;
|
||||
self.prev_volume = Some(volume);
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let n = self.period as f64;
|
||||
let new_ag = (ag * (n - 1.0) + gain) / n;
|
||||
let new_al = (al * (n - 1.0) + loss) / n;
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
let v = Self::rsi_from_avgs(new_ag, new_al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gains += gain;
|
||||
self.seed_losses += loss;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let n = self.period as f64;
|
||||
let ag = self.seed_gains / n;
|
||||
let al = self.seed_losses / n;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rsi_from_avgs(ag, al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_volume = None;
|
||||
self.seed_gains = 0.0;
|
||||
self.seed_losses = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolumeRsi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Candle whose only material field here is `volume`.
|
||||
fn vol_candle(volume: f64) -> Candle {
|
||||
Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let v = VolumeRsi::new(14).unwrap();
|
||||
assert_eq!(v.period(), 14);
|
||||
assert_eq!(v.warmup_period(), 15);
|
||||
assert_eq!(v.name(), "VolumeRsi");
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut v = VolumeRsi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
|
||||
let out = v.batch(&candles);
|
||||
// warmup_period == period + 1 == 4: first emission at index 3.
|
||||
for o in out.iter().take(3) {
|
||||
assert!(o.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_volume_is_one_hundred() {
|
||||
// Every change positive -> avg_loss 0 -> RSI 100.
|
||||
let mut v = VolumeRsi::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
|
||||
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_volume_is_zero() {
|
||||
let mut v = VolumeRsi::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (1..=40)
|
||||
.map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
|
||||
.collect();
|
||||
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_volume_is_neutral() {
|
||||
// Unchanged volume -> no gains and no losses -> neutral 50.
|
||||
let mut v = VolumeRsi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
|
||||
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut v = VolumeRsi::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
|
||||
.collect();
|
||||
for o in v.batch(&candles).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&o));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut v = VolumeRsi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| vol_candle(1_000.0 + f64::from(i)))
|
||||
.collect();
|
||||
v.batch(&candles);
|
||||
assert!(v.is_ready());
|
||||
v.reset();
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.value(), None);
|
||||
assert_eq!(v.update(vol_candle(1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
|
||||
.collect();
|
||||
let batch = VolumeRsi::new(14).unwrap().batch(&candles);
|
||||
let mut b = VolumeRsi::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Volume-Weighted MACD — MACD built on volume-weighted moving averages.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::vwma::Vwma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the
|
||||
/// fast and slow averages volume-weighted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VolumeWeightedMacdOutput {
|
||||
/// Fast VWMA − slow VWMA.
|
||||
pub macd: f64,
|
||||
/// EMA of `macd` over the signal period.
|
||||
pub signal: f64,
|
||||
/// `macd − signal`.
|
||||
pub histogram: f64,
|
||||
}
|
||||
|
||||
/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted**
|
||||
/// moving averages instead of plain EMAs.
|
||||
///
|
||||
/// ```text
|
||||
/// macd = VWMA(close, fast) − VWMA(close, slow)
|
||||
/// signal = EMA(macd, signal_period)
|
||||
/// histogram = macd − signal
|
||||
/// ```
|
||||
///
|
||||
/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential
|
||||
/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and
|
||||
/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate
|
||||
/// the trend estimate and the oscillator leans toward where real participation
|
||||
/// occurred. Crossovers backed by volume therefore appear sooner and noise from
|
||||
/// thin bars is damped. The signal line keeps a standard EMA, matching the
|
||||
/// classic histogram construction.
|
||||
///
|
||||
/// `fast` must be strictly smaller than `slow`. The first output lands after
|
||||
/// `slow + signal − 1` inputs: `slow` to seed the slow VWMA, then `signal − 1`
|
||||
/// more to seed the signal EMA. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd};
|
||||
///
|
||||
/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolumeWeightedMacd {
|
||||
fast: Vwma,
|
||||
slow: Vwma,
|
||||
signal_ema: Ema,
|
||||
fast_period: usize,
|
||||
slow_period: usize,
|
||||
signal_period: usize,
|
||||
last: Option<VolumeWeightedMacdOutput>,
|
||||
}
|
||||
|
||||
impl VolumeWeightedMacd {
|
||||
/// Construct a volume-weighted MACD with the given periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is zero, and
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
|
||||
if fast == 0 || slow == 0 || signal == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if fast >= slow {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "fast period must be strictly less than slow period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fast: Vwma::new(fast)?,
|
||||
slow: Vwma::new(slow)?,
|
||||
signal_ema: Ema::new(signal)?,
|
||||
fast_period: fast,
|
||||
slow_period: slow,
|
||||
signal_period: signal,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured periods as `(fast, slow, signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.fast_period, self.slow_period, self.signal_period)
|
||||
}
|
||||
|
||||
/// Most recent fully-computed output if available.
|
||||
pub const fn value(&self) -> Option<VolumeWeightedMacdOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VolumeWeightedMacd {
|
||||
type Input = Candle;
|
||||
type Output = VolumeWeightedMacdOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedMacdOutput> {
|
||||
let fast = self.fast.update(candle);
|
||||
let slow = self.slow.update(candle);
|
||||
if let (Some(f), Some(s)) = (fast, slow) {
|
||||
let macd = f - s;
|
||||
let signal = self.signal_ema.update(macd)?;
|
||||
let out = VolumeWeightedMacdOutput {
|
||||
macd,
|
||||
signal,
|
||||
histogram: macd - signal,
|
||||
};
|
||||
self.last = Some(out);
|
||||
return Some(out);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
self.signal_ema.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.slow_period + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VolumeWeightedMacd"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
VolumeWeightedMacd::new(0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
VolumeWeightedMacd::new(26, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
VolumeWeightedMacd::new(12, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let m = VolumeWeightedMacd::new(12, 26, 9).unwrap();
|
||||
assert_eq!(m.periods(), (12, 26, 9));
|
||||
assert_eq!(m.warmup_period(), 34);
|
||||
assert_eq!(m.name(), "VolumeWeightedMacd");
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
|
||||
.collect();
|
||||
let out = m.batch(&candles);
|
||||
let warmup = m.warmup_period(); // 4 + 3 - 1 = 6
|
||||
assert_eq!(warmup, 6);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_has_positive_macd() {
|
||||
// A steady advance with equal volume -> fast VWMA leads slow -> macd > 0.
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
|
||||
.collect();
|
||||
let last = m.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last.macd > 0.0,
|
||||
"uptrend should give positive macd, got {}",
|
||||
last.macd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_is_macd_minus_signal() {
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for o in m.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_volume_matches_plain_macd() {
|
||||
// With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses
|
||||
// SMA-based lines; it should still be a well-defined finite series.
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0))
|
||||
.collect();
|
||||
for o in m.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.macd.is_finite() && o.signal.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
|
||||
.collect();
|
||||
m.batch(&candles);
|
||||
assert!(m.is_ready());
|
||||
m.reset();
|
||||
assert!(!m.is_ready());
|
||||
assert_eq!(m.value(), None);
|
||||
assert_eq!(m.update(candle(100.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles);
|
||||
let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Williams Accumulation/Distribution — a cumulative price-only line that adds
|
||||
/// the day's accumulation on up-closes and subtracts the day's distribution on
|
||||
/// down-closes.
|
||||
///
|
||||
/// ```text
|
||||
/// if close > prev_close: AD = close − min(low, prev_close) (true low)
|
||||
/// if close < prev_close: AD = close − max(high, prev_close) (true high)
|
||||
/// if close = prev_close: AD = 0
|
||||
/// WAD_t = WAD_{t−1} + AD
|
||||
/// ```
|
||||
///
|
||||
/// Larry Williams' A/D line (distinct from Chaikin's volume-based
|
||||
/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as
|
||||
/// how far price closed above the *true low* on up-days and distribution as how
|
||||
/// far it closed below the *true high* on down-days, then accumulates the result.
|
||||
/// A rising WAD that diverges from a flat or falling price is the classic
|
||||
/// accumulation signal; a falling WAD under a rising price warns of distribution.
|
||||
///
|
||||
/// The line is unbounded and its absolute level is meaningless — only its slope
|
||||
/// and divergences against price matter. The first candle has no previous close,
|
||||
/// so it seeds the reference and emits nothing; thereafter every bar emits the
|
||||
/// running total. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Wad};
|
||||
///
|
||||
/// let mut indicator = Wad::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Wad {
|
||||
prev_close: Option<f64>,
|
||||
line: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Wad {
|
||||
/// Construct a new Williams A/D line. The line is parameter-free.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Wad {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev_close) = self.prev_close else {
|
||||
self.prev_close = Some(candle.close);
|
||||
return None;
|
||||
};
|
||||
let ad = if candle.close > prev_close {
|
||||
candle.close - candle.low.min(prev_close)
|
||||
} else if candle.close < prev_close {
|
||||
candle.close - candle.high.max(prev_close)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.line += ad;
|
||||
self.prev_close = Some(candle.close);
|
||||
self.last = Some(self.line);
|
||||
Some(self.line)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.line = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first bar only seeds the reference close; the first value lands on
|
||||
// the second bar.
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Wad"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let wad = Wad::new();
|
||||
assert_eq!(wad.warmup_period(), 2);
|
||||
assert_eq!(wad.name(), "Wad");
|
||||
assert!(!wad.is_ready());
|
||||
assert_eq!(wad.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_output() {
|
||||
let mut wad = Wad::new();
|
||||
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
|
||||
assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_close_accumulates() {
|
||||
// close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100;
|
||||
// AD = 101 - 100 = 1.
|
||||
let mut wad = Wad::new();
|
||||
wad.update(candle(101.0, 99.0, 100.0));
|
||||
let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap();
|
||||
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_close_distributes() {
|
||||
// close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101;
|
||||
// AD = 99 - 101 = -2.
|
||||
let mut wad = Wad::new();
|
||||
wad.update(candle(102.0, 100.0, 100.0));
|
||||
let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap();
|
||||
assert_relative_eq!(v, -2.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_adds_nothing() {
|
||||
let mut wad = Wad::new();
|
||||
wad.update(candle(101.0, 99.0, 100.0));
|
||||
let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_monotone() {
|
||||
let mut wad = Wad::new();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut prev = f64::NEG_INFINITY;
|
||||
for v in wad.batch(&candles).into_iter().flatten() {
|
||||
assert!(v >= prev, "WAD must rise in an uptrend");
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut wad = Wad::new();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
wad.batch(&candles);
|
||||
assert!(wad.is_ready());
|
||||
wad.reset();
|
||||
assert!(!wad.is_ready());
|
||||
assert_eq!(wad.value(), None);
|
||||
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let batch = Wad::new().batch(&candles);
|
||||
let mut b = Wad::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -57,93 +57,100 @@ pub use derivatives::DerivativesTick;
|
||||
pub use error::{Error, Result};
|
||||
pub use indicators::{
|
||||
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
|
||||
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
|
||||
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
|
||||
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
|
||||
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
|
||||
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
|
||||
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
|
||||
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
|
||||
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
|
||||
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
|
||||
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
|
||||
ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
|
||||
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
|
||||
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
|
||||
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
|
||||
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
|
||||
DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
|
||||
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
|
||||
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
|
||||
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
|
||||
Engulfing, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama,
|
||||
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
|
||||
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
|
||||
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
|
||||
FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
|
||||
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
|
||||
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
|
||||
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
|
||||
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
|
||||
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
|
||||
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
|
||||
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
|
||||
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
|
||||
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
|
||||
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
|
||||
KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
|
||||
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
|
||||
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
|
||||
LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine,
|
||||
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator,
|
||||
MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold,
|
||||
MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, McGinleyDynamic,
|
||||
MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa, MedianPrice, Mfi,
|
||||
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom,
|
||||
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, NrtrOutput, Nvi,
|
||||
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
|
||||
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
|
||||
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCci, AdaptiveCycle,
|
||||
AdaptiveLaguerreFilter, AdaptiveRsi, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio,
|
||||
Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi,
|
||||
AnchoredVwap, AndrewsPitchfork, AndrewsPitchforkOutput, Apo, Aroon, AroonOscillator,
|
||||
AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput, AtrTrailingStop,
|
||||
AutoFib, AutoFibOutput, Autocorrelation, AutocorrelationPeriodogram, AverageDailyRange,
|
||||
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower,
|
||||
BandpassFilter, Bat, BeltHold, Beta, BetaNeutralSpread, BetterVolume, BipowerVariation,
|
||||
BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput,
|
||||
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
|
||||
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, CentralPivotRange,
|
||||
CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
|
||||
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
|
||||
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
|
||||
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
|
||||
Coppock, CorrelationTrendIndicator, Counterattack, Crab, CumulativeVolumeDelta,
|
||||
CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile,
|
||||
DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
|
||||
DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev, DisparityIndex,
|
||||
DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput,
|
||||
DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo,
|
||||
DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex, EaseOfMovement, EffectiveSpread,
|
||||
EhlersStochastic, Ehma, ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone,
|
||||
ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition, Engulfing, EvenBetterSinewave,
|
||||
EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs,
|
||||
FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension,
|
||||
FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement,
|
||||
FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput,
|
||||
FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
|
||||
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
|
||||
FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley,
|
||||
GatorOscillator, GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket,
|
||||
GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
|
||||
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
|
||||
HighWave, HighpassFilter, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility,
|
||||
Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
|
||||
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
|
||||
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma,
|
||||
JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop,
|
||||
KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion,
|
||||
Keltner, KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
|
||||
KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput,
|
||||
LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope,
|
||||
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji,
|
||||
LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram,
|
||||
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
|
||||
MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
|
||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa,
|
||||
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop,
|
||||
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, MurreyMathLines,
|
||||
MurreyMathLinesOutput, Natr, NewHighsNewLows, Nrtr, NrtrOutput, Nvi, OIPriceDivergence,
|
||||
OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange,
|
||||
OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN,
|
||||
OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
|
||||
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
|
||||
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
|
||||
PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram,
|
||||
ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe,
|
||||
QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread,
|
||||
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
|
||||
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
|
||||
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
|
||||
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
|
||||
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
|
||||
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
|
||||
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
|
||||
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
|
||||
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
||||
VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
|
||||
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
|
||||
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
|
||||
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
PivotReversal, PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo,
|
||||
PpoHistogram, ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar,
|
||||
Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared,
|
||||
RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, Reflex, RegimeLabel,
|
||||
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
|
||||
RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
|
||||
RollingCorrelation, RollingCovariance, RollingIqr, RollingMinMaxScaler, RollingPercentileRank,
|
||||
RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput,
|
||||
SampleEntropy, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput,
|
||||
SessionRange, SessionRangeOutput, SessionVwap, ShannonEntropy, Shark, SharpeRatio,
|
||||
ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
|
||||
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
|
||||
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
|
||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
|
||||
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCamouflage, TdClop,
|
||||
TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
|
||||
TdMovingAverage, TdMovingAverageOutput, TdOpen, TdPressure, TdPropulsion, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile,
|
||||
TpoProfileOutput, TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex,
|
||||
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi,
|
||||
Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows,
|
||||
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UniversalOscillator,
|
||||
UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
|
||||
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone,
|
||||
VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, VolumeWeightedSr,
|
||||
VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
|
||||
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
|
||||
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
|
||||
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
@@ -160,4 +167,4 @@ pub use indicators::PnfColumn;
|
||||
pub use indicators::RenkoBrick;
|
||||
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
||||
pub use ohlcv::{Candle, Tick};
|
||||
pub use traits::{BarBuilder, BatchExt, Chain, Indicator};
|
||||
pub use traits::{BarBuilder, BatchExt, BatchNanExt, Chain, Indicator};
|
||||
|
||||
@@ -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
@@ -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 **440 indicators** across
|
||||
- A per-indicator deep dive for every one of the **474 indicators** across
|
||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||
|
||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.7",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -26,12 +26,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.6.2",
|
||||
"wickra-darwin-x64": "0.6.2",
|
||||
"wickra-linux-arm64-gnu": "0.6.2",
|
||||
"wickra-linux-x64-gnu": "0.6.2",
|
||||
"wickra-win32-arm64-msvc": "0.6.2",
|
||||
"wickra-win32-x64-msvc": "0.6.2"
|
||||
"wickra-darwin-arm64": "0.6.7",
|
||||
"wickra-darwin-x64": "0.6.7",
|
||||
"wickra-linux-arm64-gnu": "0.6.7",
|
||||
"wickra-linux-x64-gnu": "0.6.7",
|
||||
"wickra-win32-arm64-msvc": "0.6.7",
|
||||
"wickra-win32-x64-msvc": "0.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
|
||||
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
||||
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
|
||||
@@ -122,6 +122,10 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| RollingQuantile::new(20, 0.5).unwrap(), &data);
|
||||
drive(|| RollingIqr::new(14).unwrap(), &data);
|
||||
drive(|| RollingPercentileRank::new(14).unwrap(), &data);
|
||||
drive(|| JarqueBera::new(20).unwrap(), &data);
|
||||
drive(|| RollingMinMaxScaler::new(20).unwrap(), &data);
|
||||
drive(|| ShannonEntropy::new(20, 8).unwrap(), &data);
|
||||
drive(|| SampleEntropy::new(20, 2, 0.2).unwrap(), &data);
|
||||
drive(|| TrendLabel::new(14).unwrap(), &data);
|
||||
drive(|| JumpIndicator::new(20, 3.0).unwrap(), &data);
|
||||
drive(|| RegimeLabel::new(5, 20).unwrap(), &data);
|
||||
@@ -175,6 +179,15 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| CyberneticCycle::new(10).unwrap(), &data);
|
||||
drive(|| InstantaneousTrendline::new(20).unwrap(), &data);
|
||||
drive(|| EhlersStochastic::new(20).unwrap(), &data);
|
||||
drive(|| HighpassFilter::new(48).unwrap(), &data);
|
||||
drive(|| Reflex::new(20).unwrap(), &data);
|
||||
drive(|| Trendflex::new(20).unwrap(), &data);
|
||||
drive(|| CorrelationTrendIndicator::new(20).unwrap(), &data);
|
||||
drive(|| AdaptiveRsi::new(14).unwrap(), &data);
|
||||
drive(|| UniversalOscillator::new(20).unwrap(), &data);
|
||||
drive(|| BandpassFilter::new(20, 0.3).unwrap(), &data);
|
||||
drive(|| EvenBetterSinewave::new(40, 10).unwrap(), &data);
|
||||
drive(|| AutocorrelationPeriodogram::new(10, 48).unwrap(), &data);
|
||||
drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data);
|
||||
drive(HilbertDominantCycle::new, &data);
|
||||
drive(HtDcPhase::new, &data);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! WeightedClose.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, CentralPivotRange, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, MurreyMathLines, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PivotReversal, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure, TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, VolumeWeightedSr, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
|
||||
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
|
||||
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
|
||||
@@ -118,6 +118,7 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
|
||||
// --- Momentum & Oscillators ---
|
||||
drive(|| Cci::new(20).unwrap(), &candles);
|
||||
drive(|| AdaptiveCci::new(20).unwrap(), &candles);
|
||||
drive(|| StochasticCci::new(14).unwrap(), &candles);
|
||||
drive(|| ElderRay::new(13).unwrap(), &candles);
|
||||
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
|
||||
@@ -140,6 +141,13 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(HighLowRange::new, &candles);
|
||||
|
||||
// --- Volume ---
|
||||
drive(|| BetterVolume::new(14).unwrap(), &candles);
|
||||
drive(IntradayIntensity::new, &candles);
|
||||
drive(|| TradeVolumeIndex::new(0.25).unwrap(), &candles);
|
||||
drive(|| TwiggsMoneyFlow::new(21).unwrap(), &candles);
|
||||
drive(Wad::new, &candles);
|
||||
drive(|| VolumeRsi::new(14).unwrap(), &candles);
|
||||
drive(|| VolumeWeightedMacd::new(12, 26, 9).unwrap(), &candles);
|
||||
drive(Obv::new, &candles);
|
||||
drive(|| Mfi::new(14).unwrap(), &candles);
|
||||
drive(Vwap::new, &candles);
|
||||
@@ -307,6 +315,13 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
}
|
||||
|
||||
// --- Candlestick Patterns (family 14) ---
|
||||
drive(TdTrap::new, &candles);
|
||||
drive(TdPropulsion::new, &candles);
|
||||
drive(TdClopwin::new, &candles);
|
||||
drive(TdClop::new, &candles);
|
||||
drive(TdCamouflage::new, &candles);
|
||||
drive(|| TdDWave::new(2).unwrap(), &candles);
|
||||
drive(|| TdMovingAverage::new(5, 13).unwrap(), &candles);
|
||||
drive(ConcealingBabySwallow::new, &candles);
|
||||
drive(UniqueThreeRiver::new, &candles);
|
||||
drive(TasukiGap::new, &candles);
|
||||
@@ -425,4 +440,11 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(FibExtension::new, &candles);
|
||||
drive(FibRetracement::new, &candles);
|
||||
|
||||
// --- Pivots & S/R ---
|
||||
drive(CentralPivotRange::new, &candles);
|
||||
drive(|| MurreyMathLines::new(4).unwrap(), &candles);
|
||||
drive(|| AndrewsPitchfork::new(2).unwrap(), &candles);
|
||||
drive(|| VolumeWeightedSr::new(3).unwrap(), &candles);
|
||||
drive(|| PivotReversal::new(1, 1).unwrap(), &candles);
|
||||
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! panic.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
|
||||
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, KendallTau, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
|
||||
@@ -47,6 +47,7 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(|| VarianceRatio::new(60, 2).unwrap(), &pairs);
|
||||
drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs);
|
||||
drive(|| SpreadAr1Coefficient::new(40).unwrap(), &pairs);
|
||||
drive(|| KendallTau::new(20).unwrap(), &pairs);
|
||||
|
||||
// Struct-output pair indicator: drive update + batch directly (the generic
|
||||
// `drive` above only covers `Output = f64`).
|
||||
|
||||
Reference in New Issue
Block a user