Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc415a77fd | |||
| e385734275 | |||
| 3a46b210bb | |||
| 943825d6a0 | |||
| d16df1e224 | |||
| 34c097aee2 | |||
| acd7e8dc52 | |||
| ceaeb90a22 | |||
| 57e26fb22f | |||
| 8431b1400c | |||
| ed01604a18 | |||
| 05fe7ffa90 | |||
| e97c3389fe | |||
| 4526278fa0 | |||
| 80850c81f7 |
@@ -53,6 +53,22 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# CARGO_NET_RETRY rides out short blips, but a longer runner DNS outage
|
||||
# outlasts cargo's rapid in-process retries: the crates.io index fetch
|
||||
# the first cargo step does ("Could not resolve host: index.crates.io")
|
||||
# then fails the whole job. Pre-fetch the dependency graph here with real
|
||||
# backoff so clippy/build/test resolve from the warmed local cache.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -232,6 +248,19 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# See the rust job: pre-fetch with backoff so the clippy index update
|
||||
# can't fail the job on a transient "Could not resolve host" DNS blip.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
|
||||
@@ -272,6 +301,19 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# See the rust job: pre-fetch with backoff so the first cargo step can't
|
||||
# fail the job on a transient "Could not resolve host" DNS blip.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Benchmarks
|
||||
|
||||
Read these as **relative** speedups on identical input — absolute µs depend on
|
||||
CPU, memory clock and OS scheduler, not a universal contract. **Streaming is the
|
||||
headline**: it is where Wickra's design pays off and where the gap is measured in
|
||||
orders of magnitude, not percent. The batch numbers come second and are shown
|
||||
honestly — the leanest crates edge Wickra out on the simple recurrences, and that
|
||||
is a deliberate trade for warmup/NaN semantics, not a ceiling.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
|
||||
- **Reproduce yourself:**
|
||||
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
|
||||
- Python vs Python libs: `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
|
||||
|
||||
## 1. Streaming — the structural win
|
||||
|
||||
Live trading feeds one tick at a time. Wickra updates every indicator in **O(1)**;
|
||||
batch-only libraries (TA-Lib, tulipy, finta, pandas-ta) have no incremental API
|
||||
and must recompute the whole history on every tick. Only `talipp` (Python) and
|
||||
`ta-rs` / `yata` (Rust) carry real per-tick state. This is the gap the library
|
||||
was built to expose.
|
||||
|
||||
**Python — per-tick latency** (seed 5 000 bars, then feed ticks one at a time):
|
||||
|
||||
| Indicator | **★ Wickra** | talipp | TA-Lib (recompute) |
|
||||
|------------------|------------------:|------------------|-----------------------|
|
||||
| SMA(20) | **0.089 µs ★** | 0.96 µs (11×) | 422 µs (4 700×) |
|
||||
| EMA(20) | **0.111 µs ★** | 1.19 µs (11×) | 430 µs (3 900×) |
|
||||
| RSI(14) | **0.061 µs ★** | 0.95 µs (16×) | 298 µs (4 900×) |
|
||||
| MACD(12, 26, 9) | **0.079 µs ★** | 3.30 µs (42×) | 327 µs (4 100×) |
|
||||
| Bollinger(20, 2) | **0.089 µs ★** | 4.97 µs (56×) | 296 µs (3 300×) |
|
||||
|
||||
Against the only other incremental Python peer Wickra is **11–56× faster**;
|
||||
against the recompute-on-every-tick libraries it is **2 800–19 000× faster**
|
||||
(`finta` RSI hits 19 000×). tulipy / pandas-ta land in the same recompute band
|
||||
as TA-Lib.
|
||||
|
||||
**Rust — per-tick latency** (whole 50 000-bar series, lower = faster):
|
||||
|
||||
| Indicator | **★ Wickra** | kand | ta-rs | yata |
|
||||
|------------------|------------------:|-----:|------:|-----:|
|
||||
| SMA(20) | 50 | 38 | 47 | 38 |
|
||||
| EMA(20) | 154 | 69 | 56 | 69 |
|
||||
| RSI(14) | 164 | 216 | 74 | — |
|
||||
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
|
||||
| Bollinger(20, 2) | **128 ★** | 248 | 168 | — |
|
||||
| ATR(14) | 152 | 166 | 61 | — |
|
||||
|
||||
`ta-rs` hands back a bare `f64` from the first tick with no warmup and no
|
||||
validation; it leads several rows by giving those guarantees up. Against `kand`,
|
||||
Wickra wins streaming RSI, Bollinger and ATR. `yata` exposes only SMA/EMA as
|
||||
raw-value methods, so its other rows are omitted rather than faked.
|
||||
|
||||
## 2. Batch — competitive, not the headline
|
||||
|
||||
Whole series in one call. Here hand-tuned C (`tulipy`, TA-Lib) and the leanest
|
||||
Rust crate (`kand`) win the simple recurrences — Wickra trades a few µs per pass
|
||||
for the `None`-warmup, NaN-safety and bit-exact `batch == streaming` guarantees
|
||||
none of them keep. It still wins several rows outright and beats the rest of the
|
||||
field everywhere.
|
||||
|
||||
**Python** (20 000-bar pass, µs/op, lower = faster):
|
||||
|
||||
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta | finta |
|
||||
|------------------|---------:|---------:|---------:|----------:|---------:|
|
||||
| SMA(20) | 22.2 | **15.6** | 15.9 | 32.7 | 290.1 |
|
||||
| EMA(20) | 30.5 | **30.4** | 30.9 | 46.7 | 198.5 |
|
||||
| RSI(14) | 52.3 | 72.0 | **34.2** | 88.8 | 812.3 |
|
||||
| MACD(12, 26, 9) | 129.8 | 111.1 | **38.4** | 286.8 | 716.7 |
|
||||
| Bollinger(20, 2) | 87.2 | 74.6 | **37.9** | 474.3 | 1255.5 |
|
||||
| ATR(14) | 74.7 | 87.3 | **35.5** | — | 3496.4 |
|
||||
|
||||
Wickra beats pandas-ta and finta on every row and TA-Lib on RSI and ATR;
|
||||
tulipy's SIMD C (and TA-Lib on SMA/EMA) lead the remaining rows.
|
||||
|
||||
**Rust** (50 000-bar pass, µs, lower = faster). Only Wickra and `kand` expose a
|
||||
batch API; `ta-rs` and `yata` are streaming-only:
|
||||
|
||||
| Indicator | **★ Wickra** | kand |
|
||||
|------------------|------------------:|-------:|
|
||||
| SMA(20) | 53 | **41** |
|
||||
| EMA(20) | 111 | **71** |
|
||||
| RSI(14) | **221 ★** | 259 |
|
||||
| MACD(12, 26, 9) | 533 | **327** |
|
||||
| Bollinger(20, 2) | **404 ★** | 460 |
|
||||
| ATR(14) | **122 ★** | 169 |
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
|
||||
pip install -e bindings/python[bench] # Python peers
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
+57
-1
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] - 2026-06-08
|
||||
- **Hasbrouck Information Share** — variance-ratio proxy for each venue's share of price discovery (Hasbrouck information share) (`HasbrouckInformationShare`).
|
||||
- **PIN** — probability of informed trading from rolling buy/sell imbalance (EKOP single-window estimator) (`Pin`).
|
||||
- **Trade-Sign Autocorrelation** — lag-1 autocorrelation of the signed trade aggressor (order-flow persistence) (`TradeSignAutocorrelation`).
|
||||
|
||||
## [0.6.9] - 2026-06-08
|
||||
- **Tristar** — a three-doji star reversal: three consecutive dojis with the middle gapped above (bearish) or below (bullish) its neighbours (`Tristar`).
|
||||
- **Harami Cross** — a Harami whose second candle is a contained doji, a stronger reversal than a plain Harami (`HaramiCross`).
|
||||
- **Tower Top/Bottom** — a tall bar, a small pause bar, then a tall opposite bar marking a reversal (`TowerTopBottom`).
|
||||
- **Frying Pan Bottom** — a rounded (U-shaped) accumulation base over the lookback window, confirmed when price recovers above the rim (`FryPanBottom`).
|
||||
- **Dumpling Top** — a rounded (dome-shaped) distribution top over the lookback window, confirmed when price breaks below the start (`DumplingTop`).
|
||||
- **New Price Lines** — flags a run of N consecutive new closing highs (+1) or lows (-1), the eight/ten-new-price-lines exhaustion gauge (`NewPriceLines`).
|
||||
|
||||
## [0.6.8] - 2026-06-08
|
||||
- **Smoothed Heikin-Ashi** — a Heikin-Ashi candle computed from EMA-smoothed OHLC, damping noise into a cleaner trend candle (`SmoothedHeikinAshi`).
|
||||
- **Heikin-Ashi Oscillator** — the Heikin-Ashi candle body (`ha_close − ha_open`), optionally EMA-smoothed, as a zero-line oscillator (`HeikinAshiOscillator`).
|
||||
- **Three Line Break** — the trend direction of a line-break chart, reversing only when the close breaks the extreme of the last N lines (`ThreeLineBreak`).
|
||||
- **Equivolume** — a chart box whose height is the bar range and whose width is volume-relative, fusing price range with activity (`Equivolume`).
|
||||
- **CandleVolume** — a candle whose body is close-minus-open and whose width is volume-relative, a volume-weighted candle chart (`CandleVolume`).
|
||||
|
||||
## [0.6.7] - 2026-06-08
|
||||
- **TD Camouflage** — a DeMark qualifier flagging hidden intrabar strength or weakness against the prior close (`TDCamouflage`).
|
||||
- **TD Clop** — a DeMark two-bar open/close engulfing reversal where the bar opens beyond and closes back across the prior body (`TDClop`).
|
||||
- **TD Clopwin** — the inside-body cousin of TD Clop, marking a compression bar whose direction hints at the next move (`TDClopwin`).
|
||||
- **TD Propulsion** — a DeMark continuation thrust that opens on the trend side and closes beyond the prior bar's extreme (`TDPropulsion`).
|
||||
- **TD Trap** — an inside ("trap") bar followed by a close beyond its range, triggering a directional breakout signal (`TDTrap`).
|
||||
- **TD D-Wave** — a streaming Elliott-style swing-wave counter labelling the market's 1–5 impulse / A–C correction sequence (`TDDWave`).
|
||||
- **TD Moving Averages** — the DeMark ST1 (fast) and ST2 (slow) median-price trend ribbon whose crossover frames the trend (`TDMovingAverage`).
|
||||
|
||||
## [0.6.6] - 2026-06-08
|
||||
- **Pivot Reversal** — a breakout signal when price closes through the most recently confirmed swing pivot (`PIVOT_REVERSAL`).
|
||||
- **Volume-Weighted Support/Resistance** — a band whose edges are the volume-weighted average of recent highs and lows (`VOLUME_WEIGHTED_SR`).
|
||||
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
|
||||
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
|
||||
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
|
||||
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
|
||||
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
|
||||
|
||||
## [0.6.5] - 2026-06-07
|
||||
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
|
||||
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
|
||||
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
|
||||
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
|
||||
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
|
||||
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
|
||||
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
|
||||
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
|
||||
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
|
||||
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
|
||||
|
||||
## [0.6.4] - 2026-06-07
|
||||
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
|
||||
- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`).
|
||||
@@ -1332,7 +1382,13 @@ 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.4...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.7.0...HEAD
|
||||
[0.7.0]: https://github.com/wickra-lib/wickra/compare/v0.6.9...v0.7.0
|
||||
[0.6.9]: https://github.com/wickra-lib/wickra/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/wickra-lib/wickra/compare/v0.6.7...v0.6.8
|
||||
[0.6.7]: https://github.com/wickra-lib/wickra/compare/v0.6.6...v0.6.7
|
||||
[0.6.6]: https://github.com/wickra-lib/wickra/compare/v0.6.5...v0.6.6
|
||||
[0.6.5]: https://github.com/wickra-lib/wickra/compare/v0.6.4...v0.6.5
|
||||
[0.6.4]: https://github.com/wickra-lib/wickra/compare/v0.6.3...v0.6.4
|
||||
[0.6.3]: https://github.com/wickra-lib/wickra/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
|
||||
|
||||
Generated
+8
-8
@@ -1944,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1955,7 +1955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
@@ -1967,7 +1967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1977,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2004,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -2014,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -2023,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
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.4" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.7.0" }
|
||||
|
||||
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=452" 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=488" 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 452 indicators; start at the
|
||||
every one of the 488 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.** 488 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 488 indicators**.
|
||||
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
|
||||
faster than the only other incremental peer and **thousands of times** faster
|
||||
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
|
||||
the losses are shown, not hidden.
|
||||
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra` —
|
||||
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
|
||||
macOS · Linux · Windows.
|
||||
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
|
||||
live Binance kline feed ship in the box.
|
||||
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
|
||||
and closed-source work.
|
||||
|
||||
Every other library forces one of those compromises. Wickra doesn't:
|
||||
|
||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** |
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **488** | **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**: 452 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 **11–56× faster** than the only other incremental
|
||||
peer and **thousands of times** faster than recompute-on-every-tick libraries.
|
||||
**Batch** is competitive: it wins several rows outright and trades a few µs
|
||||
elsewhere for `None`-warmup, NaN-safety and bit-exact `batch == streaming`.
|
||||
|
||||
- **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
|
||||
|
||||
452 streaming-first indicators across twenty-four families. Every one passes the
|
||||
488 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).
|
||||
@@ -204,16 +144,16 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level, TD Camouflage, TD Clop, TD Clopwin, TD Propulsion, TD Trap, TD D-Wave, TD Moving Averages |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi, Heikin-Ashi Oscillator, Three Line Break, Smoothed Heikin-Ashi, Equivolume, CandleVolume |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
|
||||
| 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 |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow, Tristar, Harami Cross, Tower Top/Bottom, Dumpling Top, New Price Lines, Frying Pan Bottom |
|
||||
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
|
||||
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
|
||||
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure, Trade-Sign Autocorrelation, Hasbrouck Information Share |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
|
||||
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
|
||||
@@ -297,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 452 indicators
|
||||
│ ├── wickra-core/ core engine + all 488 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,15 @@ 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),
|
||||
@@ -367,6 +376,22 @@ const candleScalar = {
|
||||
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDCamouflage: { make: () => new wickra.TDCamouflage(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClop: { make: () => new wickra.TDClop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClopwin: { make: () => new wickra.TDClopwin(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDPropulsion: { make: () => new wickra.TDPropulsion(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDTrap: { make: () => new wickra.TDTrap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDDWave: { make: () => new wickra.TDDWave(2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshiOscillator: { make: () => new wickra.HeikinAshiOscillator(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeLineBreak: { make: () => new wickra.ThreeLineBreak(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Tristar: { make: () => new wickra.Tristar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HaramiCross: { make: () => new wickra.HaramiCross(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TowerTopBottom: { make: () => new wickra.TowerTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
DumplingTop: { make: () => new wickra.DumplingTop(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NewPriceLines: { make: () => new wickra.NewPriceLines(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FryPanBottom: { make: () => new wickra.FryPanBottom(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -464,6 +489,14 @@ const multi = {
|
||||
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
TDMovingAverage: { make: () => new wickra.TDMovingAverage(5, 13), fields: ['st1', 'st2'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
SmoothedHeikinAshi: { make: () => new wickra.SmoothedHeikinAshi(5), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Equivolume: { make: () => new wickra.Equivolume(20), fields: ['height', 'width'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CandleVolume: { make: () => new wickra.CandleVolume(20), fields: ['body', 'width'], step: (ind, i) => ind.update(open[i], close[i], volume[i]), batch: (ind) => ind.batch(open, close, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -634,6 +667,7 @@ const pairFactories = {
|
||||
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
|
||||
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
|
||||
KendallTau: () => new wickra.KendallTau(20),
|
||||
HasbrouckInformationShare: () => new wickra.HasbrouckInformationShare(2),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
@@ -1237,7 +1271,7 @@ test('vpin / amihud / roll reference + streaming matches batch', () => {
|
||||
const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
|
||||
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) {
|
||||
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14), () => new wickra.TradeSignAutocorrelation(10), () => new wickra.Pin(10)]) {
|
||||
const batch = make().batch(price, size, isBuy);
|
||||
const streamer = make();
|
||||
assert.equal(batch.length, n);
|
||||
@@ -1246,6 +1280,16 @@ test('vpin / amihud / roll reference + streaming matches batch', () => {
|
||||
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
|
||||
}
|
||||
}
|
||||
// Trade-sign autocorrelation: alternating signs -> -1, all buys -> +1.
|
||||
let tsac = null;
|
||||
const tsacInd = new wickra.TradeSignAutocorrelation(10);
|
||||
for (let i = 0; i < 20; i++) tsac = tsacInd.update(100, 1, i % 2 === 0);
|
||||
assert.ok(Math.abs(tsac - -1.0) < 1e-12);
|
||||
// PIN: one-sided flow -> 1, balanced flow -> 0.
|
||||
let pin = null;
|
||||
const pinInd = new wickra.Pin(10);
|
||||
for (let i = 0; i < 20; i++) pin = pinInd.update(100, 1, true);
|
||||
assert.ok(Math.abs(pin - 1.0) < 1e-12);
|
||||
});
|
||||
|
||||
test('price-impact indicators reference values', () => {
|
||||
|
||||
Vendored
+371
@@ -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
|
||||
@@ -356,6 +385,20 @@ export interface HeikinAshiValue {
|
||||
low: number
|
||||
close: number
|
||||
}
|
||||
export interface SmoothedHeikinAshiValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
}
|
||||
export interface EquivolumeValue {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
export interface CandleVolumeValue {
|
||||
body: number
|
||||
width: number
|
||||
}
|
||||
export interface ValueAreaValue {
|
||||
poc: number
|
||||
vah: number
|
||||
@@ -1070,6 +1113,87 @@ export declare class ROLLINGMINMAX {
|
||||
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)
|
||||
@@ -1325,6 +1449,19 @@ export declare class BetaNeutralSpread {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HasbrouckInformationShareNode = HasbrouckInformationShare
|
||||
export declare class HasbrouckInformationShare {
|
||||
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 PairSpreadZScoreNode = PairSpreadZScore
|
||||
/**
|
||||
* Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
|
||||
@@ -1751,6 +1888,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)
|
||||
@@ -2840,6 +2986,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)
|
||||
@@ -2999,6 +3190,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)
|
||||
@@ -3180,6 +3389,78 @@ export declare class HeikinAshi {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HeikinAshiOscillatorNode = HeikinAshiOscillator
|
||||
export declare class HeikinAshiOscillator {
|
||||
constructor(period: number)
|
||||
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 ThreeLineBreakNode = ThreeLineBreak
|
||||
export declare class ThreeLineBreak {
|
||||
constructor(lines: 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 SmoothedHeikinAshiNode = SmoothedHeikinAshi
|
||||
export declare class SmoothedHeikinAshi {
|
||||
constructor(period: number)
|
||||
update(open: number, high: number, low: number, close: number): SmoothedHeikinAshiValue | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EquivolumeNode = Equivolume
|
||||
export declare class Equivolume {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, volume: number): EquivolumeValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CandleVolumeNode = CandleVolume
|
||||
export declare class CandleVolume {
|
||||
constructor(period: number)
|
||||
update(open: number, close: number, volume: number): CandleVolumeValue | null
|
||||
batch(open: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FryPanBottomNode = FryPanBottom
|
||||
export declare class FryPanBottom {
|
||||
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 DumplingTopNode = DumplingTop
|
||||
export declare class DumplingTop {
|
||||
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 NewPriceLinesNode = NewPriceLines
|
||||
export declare class NewPriceLines {
|
||||
constructor(count: 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 ValueAreaNode = ValueArea
|
||||
export declare class ValueArea {
|
||||
constructor(period: number, binCount: number, valueAreaPct: number)
|
||||
@@ -3912,6 +4193,78 @@ 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 TristarNode = Tristar
|
||||
export declare class Tristar {
|
||||
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 HaramiCrossNode = HaramiCross
|
||||
export declare class HaramiCross {
|
||||
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 TowerTopBottomNode = TowerTopBottom
|
||||
export declare class TowerTopBottom {
|
||||
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()
|
||||
@@ -3993,6 +4346,24 @@ export declare class TradeImbalance {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TradeSignAutocorrelationNode = TradeSignAutocorrelation
|
||||
export declare class TradeSignAutocorrelation {
|
||||
constructor(period: number)
|
||||
update(price: number, size: number, isBuy: boolean): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PinNode = Pin
|
||||
export declare class Pin {
|
||||
constructor(window: number)
|
||||
update(price: number, size: number, isBuy: boolean): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OrderFlowImbalanceNode = OrderFlowImbalance
|
||||
export declare class OrderFlowImbalance {
|
||||
constructor(period: number)
|
||||
|
||||
+37
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.6.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.6.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"wickra-darwin-x64": "0.6.4",
|
||||
"wickra-linux-arm64-gnu": "0.6.4",
|
||||
"wickra-linux-x64-gnu": "0.6.4",
|
||||
"wickra-win32-arm64-msvc": "0.6.4",
|
||||
"wickra-win32-x64-msvc": "0.6.4"
|
||||
"wickra-darwin-arm64": "0.7.0",
|
||||
"wickra-darwin-x64": "0.7.0",
|
||||
"wickra-linux-arm64-gnu": "0.7.0",
|
||||
"wickra-linux-x64-gnu": "0.7.0",
|
||||
"wickra-win32-arm64-msvc": "0.7.0",
|
||||
"wickra-win32-x64-msvc": "0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.4.tgz",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.7.0.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.4.tgz",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.7.0.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.4.tgz",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.7.0.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.4.tgz",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.7.0.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.4.tgz",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.7.0.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.4.tgz",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.7.0.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.6.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"wickra-linux-arm64-gnu": "0.6.4",
|
||||
"wickra-darwin-x64": "0.6.4",
|
||||
"wickra-darwin-arm64": "0.6.4",
|
||||
"wickra-win32-x64-msvc": "0.6.4",
|
||||
"wickra-win32-arm64-msvc": "0.6.4"
|
||||
"wickra-linux-x64-gnu": "0.7.0",
|
||||
"wickra-linux-arm64-gnu": "0.7.0",
|
||||
"wickra-darwin-x64": "0.7.0",
|
||||
"wickra-darwin-arm64": "0.7.0",
|
||||
"wickra-win32-x64-msvc": "0.7.0",
|
||||
"wickra-win32-arm64-msvc": "0.7.0"
|
||||
},
|
||||
"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.4"
|
||||
version = "0.7.0"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -25,6 +25,16 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
AUTOCORRPGRAM,
|
||||
EVENBETTERSINE,
|
||||
BANDPASS,
|
||||
ADAPTIVECCI,
|
||||
UNIVERSALOSC,
|
||||
ADAPTIVERSI,
|
||||
CTI,
|
||||
TRENDFLEX,
|
||||
REFLEX,
|
||||
HIGHPASS,
|
||||
SAMPLEENT,
|
||||
SHANNONENT,
|
||||
ROLLINGMINMAX,
|
||||
@@ -299,6 +309,11 @@ from ._wickra import (
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
PivotReversal,
|
||||
VolumeWeightedSr,
|
||||
AndrewsPitchfork,
|
||||
MurreyMathLines,
|
||||
CentralPivotRange,
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
@@ -307,6 +322,13 @@ from ._wickra import (
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
# DeMark
|
||||
TDMovingAverage,
|
||||
TDDWave,
|
||||
TDTrap,
|
||||
TDPropulsion,
|
||||
TDClopwin,
|
||||
TDClop,
|
||||
TDCamouflage,
|
||||
TDSetup,
|
||||
TDSequential,
|
||||
TDDeMarker,
|
||||
@@ -322,6 +344,11 @@ from ._wickra import (
|
||||
# Ichimoku & alternative charts
|
||||
Ichimoku,
|
||||
HeikinAshi,
|
||||
SmoothedHeikinAshi,
|
||||
HeikinAshiOscillator,
|
||||
ThreeLineBreak,
|
||||
Equivolume,
|
||||
CandleVolume,
|
||||
# Market Profile
|
||||
ValueArea,
|
||||
VolumeProfile,
|
||||
@@ -333,6 +360,12 @@ from ._wickra import (
|
||||
KagiBars,
|
||||
PointAndFigureBars,
|
||||
# Candlestick patterns
|
||||
TowerTopBottom,
|
||||
HaramiCross,
|
||||
Tristar,
|
||||
FryPanBottom,
|
||||
DumplingTop,
|
||||
NewPriceLines,
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
@@ -431,6 +464,8 @@ from ._wickra import (
|
||||
QuotedSpread,
|
||||
DepthSlope,
|
||||
# Microstructure: trade flow
|
||||
Pin,
|
||||
TradeSignAutocorrelation,
|
||||
RollMeasure,
|
||||
AmihudIlliquidity,
|
||||
Vpin,
|
||||
@@ -438,6 +473,7 @@ from ._wickra import (
|
||||
CumulativeVolumeDelta,
|
||||
TradeImbalance,
|
||||
# Microstructure: price impact
|
||||
HasbrouckInformationShare,
|
||||
EffectiveSpread,
|
||||
RealizedSpread,
|
||||
KylesLambda,
|
||||
@@ -506,6 +542,16 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AUTOCORRPGRAM",
|
||||
"EVENBETTERSINE",
|
||||
"BANDPASS",
|
||||
"ADAPTIVECCI",
|
||||
"UNIVERSALOSC",
|
||||
"ADAPTIVERSI",
|
||||
"CTI",
|
||||
"TRENDFLEX",
|
||||
"REFLEX",
|
||||
"HIGHPASS",
|
||||
"SAMPLEENT",
|
||||
"SHANNONENT",
|
||||
"ROLLINGMINMAX",
|
||||
@@ -781,6 +827,11 @@ __all__ = [
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"PivotReversal",
|
||||
"VolumeWeightedSr",
|
||||
"AndrewsPitchfork",
|
||||
"MurreyMathLines",
|
||||
"CentralPivotRange",
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
@@ -789,6 +840,13 @@ __all__ = [
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
# DeMark
|
||||
"TDMovingAverage",
|
||||
"TDDWave",
|
||||
"TDTrap",
|
||||
"TDPropulsion",
|
||||
"TDClopwin",
|
||||
"TDClop",
|
||||
"TDCamouflage",
|
||||
"TDSetup",
|
||||
"TDSequential",
|
||||
"TDDeMarker",
|
||||
@@ -804,6 +862,11 @@ __all__ = [
|
||||
# Ichimoku & alternative charts
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
"SmoothedHeikinAshi",
|
||||
"HeikinAshiOscillator",
|
||||
"ThreeLineBreak",
|
||||
"Equivolume",
|
||||
"CandleVolume",
|
||||
# Market Profile
|
||||
"ValueArea",
|
||||
"VolumeProfile",
|
||||
@@ -815,6 +878,12 @@ __all__ = [
|
||||
"KagiBars",
|
||||
"PointAndFigureBars",
|
||||
# Candlestick patterns
|
||||
"TowerTopBottom",
|
||||
"HaramiCross",
|
||||
"Tristar",
|
||||
"FryPanBottom",
|
||||
"DumplingTop",
|
||||
"NewPriceLines",
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
@@ -913,6 +982,8 @@ __all__ = [
|
||||
"QuotedSpread",
|
||||
"DepthSlope",
|
||||
# Microstructure: trade flow
|
||||
"Pin",
|
||||
"TradeSignAutocorrelation",
|
||||
"RollMeasure",
|
||||
"AmihudIlliquidity",
|
||||
"Vpin",
|
||||
@@ -920,6 +991,7 @@ __all__ = [
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
# Microstructure: price impact
|
||||
"HasbrouckInformationShare",
|
||||
"EffectiveSpread",
|
||||
"RealizedSpread",
|
||||
"KylesLambda",
|
||||
|
||||
+1873
-154
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,15 @@ 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,)),
|
||||
@@ -208,6 +217,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
||||
# --- Two-series (asset, benchmark) indicators -----------------------------
|
||||
|
||||
PAIR = [
|
||||
(ta.HasbrouckInformationShare, (2,)),
|
||||
(ta.KendallTau, (20,)),
|
||||
(ta.SpreadAr1Coefficient, (40,)),
|
||||
(ta.GrangerCausality, (60, 1)),
|
||||
@@ -373,6 +383,67 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"FryPanBottom": (
|
||||
lambda: ta.FryPanBottom(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"NewPriceLines": (
|
||||
lambda: ta.NewPriceLines(5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"DumplingTop": (
|
||||
lambda: ta.DumplingTop(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TowerTopBottom": (
|
||||
lambda: ta.TowerTopBottom(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"HaramiCross": (
|
||||
lambda: ta.HaramiCross(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Tristar": (
|
||||
lambda: ta.Tristar(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ThreeLineBreak": (
|
||||
lambda: ta.ThreeLineBreak(3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"HeikinAshiOscillator": (
|
||||
lambda: ta.HeikinAshiOscillator(5),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"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),
|
||||
@@ -938,6 +1009,46 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"CandleVolume": (
|
||||
lambda: ta.CandleVolume(20),
|
||||
lambda ind, h, l, c, v: ind.batch(c, c, v),
|
||||
2,
|
||||
),
|
||||
"Equivolume": (
|
||||
lambda: ta.Equivolume(20),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
2,
|
||||
),
|
||||
"SmoothedHeikinAshi": (
|
||||
lambda: ta.SmoothedHeikinAshi(5),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
4,
|
||||
),
|
||||
"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),
|
||||
@@ -3102,6 +3213,124 @@ def test_volume_weighted_macd_reference():
|
||||
def test_kendall_tau_reference():
|
||||
t = ta.KendallTau(20)
|
||||
|
||||
|
||||
def test_central_pivot_range_reference():
|
||||
t = ta.CentralPivotRange()
|
||||
assert t.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) == pytest.approx((101.66666666666667, 103.33333333333334, 100.0))
|
||||
|
||||
|
||||
def test_murrey_math_lines_reference():
|
||||
t = ta.MurreyMathLines(4)
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 0)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 1)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 2)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 3)) == pytest.approx((180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, 100.0))
|
||||
|
||||
|
||||
def test_andrews_pitchfork_reference():
|
||||
t = ta.AndrewsPitchfork(2)
|
||||
# Warmup: no pitchfork until three alternating swing pivots are confirmed.
|
||||
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
|
||||
|
||||
|
||||
def test_volume_weighted_sr_reference():
|
||||
t = ta.VolumeWeightedSr(3)
|
||||
assert t.update((100.0, 102.0, 98.0, 100.0, 1.0, 0)) is None
|
||||
assert t.update((100.0, 104.0, 96.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((100.0, 106.0, 94.0, 100.0, 1.0, 2)) == pytest.approx((96.0, 104.0))
|
||||
|
||||
|
||||
def test_pivot_reversal_reference():
|
||||
t = ta.PivotReversal(1, 1)
|
||||
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 0)) is None
|
||||
assert t.update((11.5, 12.0, 11.0, 11.5, 1.0, 1)) is None
|
||||
# Pivot high = 12 confirmed; close 9.5 has not crossed it.
|
||||
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 11.0, 9.0, 9.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
# Close 13 > pivot high 12 with prev close 9 below it -> bullish reversal.
|
||||
assert t.update((13.0, 14.0, 12.5, 13.0, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
def test_heikin_ashi_oscillator_reference():
|
||||
t = ta.HeikinAshiOscillator(5)
|
||||
|
||||
|
||||
def test_three_line_break_reference():
|
||||
t = ta.ThreeLineBreak(3)
|
||||
|
||||
|
||||
def test_smoothed_heikin_ashi_reference():
|
||||
t = ta.SmoothedHeikinAshi(5)
|
||||
|
||||
|
||||
def test_equivolume_reference():
|
||||
t = ta.Equivolume(20)
|
||||
|
||||
|
||||
def test_candle_volume_reference():
|
||||
t = ta.CandleVolume(20)
|
||||
|
||||
|
||||
def test_tristar_reference():
|
||||
t = ta.Tristar()
|
||||
assert t.update((100.0, 101.0, 99.0, 100.02, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 106.0, 104.0, 105.02, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 101.0, 99.0, 100.02, 1.0, 2)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_harami_cross_reference():
|
||||
t = ta.HaramiCross()
|
||||
assert t.update((110.0, 110.2, 99.8, 100.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 106.0, 104.0, 105.02, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_tower_top_bottom_reference():
|
||||
t = ta.TowerTopBottom()
|
||||
assert t.update((100.0, 110.1, 99.9, 110.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 107.0, 103.0, 105.1, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((110.0, 110.1, 99.9, 100.0, 1.0, 2)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
|
||||
def test_hasbrouck_information_share_reference():
|
||||
t = ta.HasbrouckInformationShare(2)
|
||||
assert t.update(7.0, 9.0) is None
|
||||
assert t.update(7.0, 9.0) is None
|
||||
assert t.update(7.0, 9.0) == pytest.approx(0.5)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -3442,6 +3671,8 @@ def test_tradeflow_indicators_streaming_equals_batch():
|
||||
lambda: ta.Vpin(8.0, 5),
|
||||
lambda: ta.AmihudIlliquidity(14),
|
||||
lambda: ta.RollMeasure(14),
|
||||
lambda: ta.TradeSignAutocorrelation(10),
|
||||
lambda: ta.Pin(10),
|
||||
):
|
||||
batch = make().batch(price, size, is_buy)
|
||||
streamer = make()
|
||||
@@ -3453,6 +3684,34 @@ def test_tradeflow_indicators_streaming_equals_batch():
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_trade_sign_autocorrelation_reference():
|
||||
# Perfectly alternating aggressor signs -> lag-1 autocorrelation -1.
|
||||
t = ta.TradeSignAutocorrelation(10)
|
||||
last = None
|
||||
for i in range(20):
|
||||
last = t.update(100.0, 1.0, i % 2 == 0)
|
||||
assert last == pytest.approx(-1.0)
|
||||
# All buys -> perfectly persistent flow -> +1.
|
||||
t2 = ta.TradeSignAutocorrelation(10)
|
||||
for _ in range(20):
|
||||
last2 = t2.update(100.0, 1.0, True)
|
||||
assert last2 == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_pin_reference():
|
||||
# One-sided flow (all buys) -> maximally informed -> PIN 1.
|
||||
p = ta.Pin(10)
|
||||
last = None
|
||||
for _ in range(20):
|
||||
last = p.update(100.0, 1.0, True)
|
||||
assert last == pytest.approx(1.0)
|
||||
# Balanced flow -> uninformed -> PIN 0.
|
||||
p2 = ta.Pin(10)
|
||||
for i in range(20):
|
||||
last2 = p2.update(100.0, 1.0, i % 2 == 0)
|
||||
assert last2 == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_price_impact_indicators_streaming_equals_batch():
|
||||
n = 40
|
||||
mid = np.array([100.0 + 0.5 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,231 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
//! CandleVolume — candlestick body with a volume-scaled width.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`CandleVolume`]: the signed candle body and its volume-relative width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CandleVolumeOutput {
|
||||
/// Signed body `close − open` (positive = bullish candle).
|
||||
pub body: f64,
|
||||
/// Box width — volume relative to its `period` average (`1.0` = average).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// CandleVolume — the candlestick analogue of [`Equivolume`](crate::Equivolume):
|
||||
/// each bar's **body** (`close − open`) paired with a **width** proportional to its
|
||||
/// volume relative to the recent average.
|
||||
///
|
||||
/// ```text
|
||||
/// body = close − open (signed; + bullish, − bearish)
|
||||
/// width = volume / SMA(volume, period) (1.0 = average volume)
|
||||
/// ```
|
||||
///
|
||||
/// Where Equivolume uses the high-low *range* for the box height, CandleVolume uses
|
||||
/// the candlestick *body*, preserving direction: a wide bullish body (long up
|
||||
/// candle on heavy volume) is strong demand, a wide bearish body strong supply, and
|
||||
/// a narrow body on heavy volume (wide but short) is churn. The signed body plus
|
||||
/// the normalised width capture both the move's direction and the participation
|
||||
/// behind it.
|
||||
///
|
||||
/// The first value lands after `period` inputs (to seed the volume average). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, CandleVolume};
|
||||
///
|
||||
/// let mut indicator = CandleVolume::new(14).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 + 0.5, 1_000.0 + f64::from(i), 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandleVolume {
|
||||
period: usize,
|
||||
vol_sma: Sma,
|
||||
last: Option<CandleVolumeOutput>,
|
||||
}
|
||||
|
||||
impl CandleVolume {
|
||||
/// Construct a CandleVolume with the given volume-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,
|
||||
vol_sma: Sma::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured volume-averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<CandleVolumeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CandleVolume {
|
||||
type Input = Candle;
|
||||
type Output = CandleVolumeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CandleVolumeOutput> {
|
||||
let avg_vol = self.vol_sma.update(candle.volume)?;
|
||||
let body = candle.close - candle.open;
|
||||
let width = if avg_vol > 0.0 {
|
||||
candle.volume / avg_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = CandleVolumeOutput { body, width };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol_sma.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CandleVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, close: f64, volume: f64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new_unchecked(open, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(CandleVolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cv = CandleVolume::new(14).unwrap();
|
||||
assert_eq!(cv.period(), 14);
|
||||
assert_eq!(cv.warmup_period(), 14);
|
||||
assert_eq!(cv.name(), "CandleVolume");
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(100.0, 101.0, 1_000.0)).collect();
|
||||
let out = cv.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_body_positive() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(100.0, 103.0, 1_000.0), c(100.0, 103.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.body, 3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_body_negative() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(103.0, 100.0, 1_000.0), c(103.0, 100.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.body, -3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bar_is_wide() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
let candles = [
|
||||
c(100.0, 101.0, 1_000.0),
|
||||
c(100.0, 101.0, 1_000.0),
|
||||
c(100.0, 101.0, 4_000.0),
|
||||
];
|
||||
let out = cv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(out.width > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
cv.batch(&[c(100.0, 101.0, 1_000.0); 6]);
|
||||
assert!(cv.is_ready());
|
||||
cv.reset();
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.value(), None);
|
||||
assert_eq!(cv.update(c(100.0, 101.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_gives_zero_width() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(10.0, 11.0, 0.0), c(11.0, 12.0, 0.0), c(12.0, 13.0, 0.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 5.0;
|
||||
c(b, b + 0.5, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = CandleVolume::new(14).unwrap().batch(&candles);
|
||||
let mut b = CandleVolume::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Dumpling Top — a rounded top (dome) confirmed by a breakdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Dumpling Top — the bearish mirror of the [`FryPanBottom`](crate::FryPanBottom):
|
||||
/// a gently rounded **top** (dome) across the window, confirmed by a close back
|
||||
/// below where it started.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `period` closes:
|
||||
/// the maximum close sits in the middle third of the window (the "dome")
|
||||
/// the latest close is below the first close (the breakdown)
|
||||
/// signal = −1 when both hold, else 0
|
||||
/// ```
|
||||
///
|
||||
/// The dumpling top is a distribution pattern: price rounds over at the top as
|
||||
/// buying fades, then rolls down through the level it rose from. Detection requires
|
||||
/// a *central* high (a symmetric dome, not a one-sided spike) and a close below the
|
||||
/// window's opening level. The output is `−1.0` (pattern) or `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` inputs; each `update` scans the window in
|
||||
/// O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, DumplingTop};
|
||||
///
|
||||
/// let mut indicator = DumplingTop::new(9).unwrap();
|
||||
/// let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
|
||||
/// let mut last = None;
|
||||
/// for &cl in &closes {
|
||||
/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumplingTop {
|
||||
period: usize,
|
||||
closes: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl DumplingTop {
|
||||
/// Construct a Dumpling Top over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 5`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 5 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "dumpling top needs period >= 5",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
closes: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window 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 DumplingTop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.closes.len() == self.period {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
self.closes.push_back(candle.close);
|
||||
if self.closes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let first = *self.closes.front().expect("non-empty");
|
||||
let last = *self.closes.back().expect("non-empty");
|
||||
let mut max_idx = 0;
|
||||
let mut max_val = f64::NEG_INFINITY;
|
||||
for (i, &v) in self.closes.iter().enumerate() {
|
||||
if v > max_val {
|
||||
max_val = v;
|
||||
max_idx = i;
|
||||
}
|
||||
}
|
||||
let lo = self.period / 4;
|
||||
let hi = self.period - self.period / 4;
|
||||
let dome = max_idx >= lo && max_idx < hi;
|
||||
let broke_down = last < first && last < max_val;
|
||||
let v = if dome && broke_down { -1.0 } else { 0.0 };
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.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 {
|
||||
"DumplingTop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_period() {
|
||||
assert!(matches!(
|
||||
DumplingTop::new(4),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(DumplingTop::new(5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DumplingTop::new(9).unwrap();
|
||||
assert_eq!(d.period(), 9);
|
||||
assert_eq!(d.warmup_period(), 9);
|
||||
assert_eq!(d.name(), "DumplingTop");
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut d = DumplingTop::new(5).unwrap();
|
||||
let out = d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0), c(98.0)]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_top_then_breakdown_signals() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_rise_is_zero() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let candles: Vec<Candle> = (0..9).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_breakdown_is_zero() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let closes = [
|
||||
100.0, 102.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5,
|
||||
];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DumplingTop::new(5).unwrap();
|
||||
d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0)]);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.value(), None);
|
||||
assert_eq!(d.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = DumplingTop::new(9).unwrap().batch(&candles);
|
||||
let mut b = DumplingTop::new(9).unwrap();
|
||||
let streamed: Vec<_> = candles.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,235 @@
|
||||
//! Equivolume — the price box height and its volume-scaled width.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`Equivolume`]: the box's price height and its volume-relative width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EquivolumeOutput {
|
||||
/// Box height — the bar's price range `high − low`.
|
||||
pub height: f64,
|
||||
/// Box width — volume relative to its `period` average (`1.0` = average).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// Equivolume — Richard Arms' charting style rendered as numbers: each bar is a
|
||||
/// "box" whose **height** is its price range and whose **width** is its volume
|
||||
/// relative to the recent average.
|
||||
///
|
||||
/// ```text
|
||||
/// height = high − low
|
||||
/// width = volume / SMA(volume, period) (1.0 = average volume)
|
||||
/// ```
|
||||
///
|
||||
/// Equivolume discards time and substitutes volume for the horizontal axis: a tall
|
||||
/// narrow box is an easy move (big range on light volume), while a short wide box
|
||||
/// is churn (small range on heavy volume) that often marks support/resistance.
|
||||
/// Reporting the two dimensions lets you reconstruct that shape programmatically:
|
||||
/// the height/width relationship is Arms' "ease of movement" read. The width is
|
||||
/// normalised by the volume SMA so it self-scales across instruments.
|
||||
///
|
||||
/// The first value lands after `period` inputs (to seed the volume average). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Equivolume};
|
||||
///
|
||||
/// let mut indicator = Equivolume::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// 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 Equivolume {
|
||||
period: usize,
|
||||
vol_sma: Sma,
|
||||
last: Option<EquivolumeOutput>,
|
||||
}
|
||||
|
||||
impl Equivolume {
|
||||
/// Construct an Equivolume with the given volume-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,
|
||||
vol_sma: Sma::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured volume-averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<EquivolumeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Equivolume {
|
||||
type Input = Candle;
|
||||
type Output = EquivolumeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<EquivolumeOutput> {
|
||||
let avg_vol = self.vol_sma.update(candle.volume)?;
|
||||
let height = candle.high - candle.low;
|
||||
let width = if avg_vol > 0.0 {
|
||||
candle.volume / avg_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = EquivolumeOutput { height, width };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol_sma.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Equivolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[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!(Equivolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = Equivolume::new(14).unwrap();
|
||||
assert_eq!(e.period(), 14);
|
||||
assert_eq!(e.warmup_period(), 14);
|
||||
assert_eq!(e.name(), "Equivolume");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect();
|
||||
let out = e.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_is_range() {
|
||||
let mut e = Equivolume::new(2).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(105.0, 100.0, 1_000.0), c(105.0, 100.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.height, 5.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_volume_width_is_one() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(102.0, 98.0, 1_000.0); 6])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.width, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bar_is_wide() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let candles = [
|
||||
c(102.0, 98.0, 1_000.0),
|
||||
c(102.0, 98.0, 1_000.0),
|
||||
c(102.0, 98.0, 4_000.0),
|
||||
];
|
||||
let out = e.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
out.width > 1.0,
|
||||
"a heavy bar should be wider than average, got {}",
|
||||
out.width
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
e.batch(&[c(102.0, 98.0, 1_000.0); 6]);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(c(102.0, 98.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_gives_zero_width() {
|
||||
let mut e = Equivolume::new(2).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(11.0, 9.0, 0.0), c(12.0, 10.0, 0.0), c(13.0, 11.0, 0.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 5.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = Equivolume::new(14).unwrap().batch(&candles);
|
||||
let mut b = Equivolume::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,218 @@
|
||||
//! Frying Pan Bottom — a rounded bottom (U) confirmed by recovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Frying Pan Bottom — a gently rounded bottom across the lookback window: prices
|
||||
/// decline, flatten near the centre, then recover above where they started.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `period` closes:
|
||||
/// the minimum close sits in the middle third of the window (the "bowl")
|
||||
/// the latest close is above the first close (the rim is recovered)
|
||||
/// signal = +1 when both hold, else 0
|
||||
/// ```
|
||||
///
|
||||
/// The frying pan is a bullish accumulation pattern: a saucer-shaped base where
|
||||
/// selling dries up, the curve flattens, and price lifts off the rim. Detecting it
|
||||
/// requires the low point to be central (a symmetric bowl, not a one-sided drop)
|
||||
/// and the close to have climbed back above the window's opening level, confirming
|
||||
/// the breakout from the base. The output is `+1.0` (pattern) or `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` inputs; each `update` scans the window in
|
||||
/// O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, FryPanBottom};
|
||||
///
|
||||
/// let mut indicator = FryPanBottom::new(9).unwrap();
|
||||
/// // A U-shaped base then recovery.
|
||||
/// let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
|
||||
/// let mut last = None;
|
||||
/// for &cl in &closes {
|
||||
/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FryPanBottom {
|
||||
period: usize,
|
||||
closes: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl FryPanBottom {
|
||||
/// Construct a Frying Pan Bottom over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 5` (a bowl needs room for a
|
||||
/// central low between recovering sides).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 5 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "frying pan bottom needs period >= 5",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
closes: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window 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 FryPanBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.closes.len() == self.period {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
self.closes.push_back(candle.close);
|
||||
if self.closes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let first = *self.closes.front().expect("non-empty");
|
||||
let last = *self.closes.back().expect("non-empty");
|
||||
// Index of the minimum close.
|
||||
let mut min_idx = 0;
|
||||
let mut min_val = f64::INFINITY;
|
||||
for (i, &v) in self.closes.iter().enumerate() {
|
||||
if v < min_val {
|
||||
min_val = v;
|
||||
min_idx = i;
|
||||
}
|
||||
}
|
||||
let lo = self.period / 4;
|
||||
let hi = self.period - self.period / 4;
|
||||
let bowl = min_idx >= lo && min_idx < hi;
|
||||
let recovered = last > first && last > min_val;
|
||||
let v = if bowl && recovered { 1.0 } else { 0.0 };
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.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 {
|
||||
"FryPanBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_period() {
|
||||
assert!(matches!(
|
||||
FryPanBottom::new(4),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(FryPanBottom::new(5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FryPanBottom::new(9).unwrap();
|
||||
assert_eq!(f.period(), 9);
|
||||
assert_eq!(f.warmup_period(), 9);
|
||||
assert_eq!(f.name(), "FryPanBottom");
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut f = FryPanBottom::new(5).unwrap();
|
||||
let out = f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0), c(102.0)]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_bottom_then_recovery_signals() {
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_drop_is_zero() {
|
||||
// A straight decline (min at the end) is not a bowl.
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let candles: Vec<Candle> = (0..9).map(|i| c(100.0 - f64::from(i))).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_recovery_is_zero() {
|
||||
// Bowl shape but the last close never climbs above the first.
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 97.0, 98.0, 99.0, 99.5];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FryPanBottom::new(5).unwrap();
|
||||
f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0)]);
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.value(), None);
|
||||
assert_eq!(f.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = FryPanBottom::new(9).unwrap().batch(&candles);
|
||||
let mut b = FryPanBottom::new(9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Harami Cross — a Harami whose second candle is a Doji.
|
||||
//!
|
||||
//! A Harami Cross is a stronger Harami: a large real body followed by a Doji whose
|
||||
//! body sits *within* the prior body. The Doji's total indecision after a strong
|
||||
//! move makes the reversal signal more potent than a plain Harami.
|
||||
//!
|
||||
//! - **Bullish** (`+1.0`): the prior candle is a large **bearish** body
|
||||
//! (`close < open`) and the current candle is a Doji whose open and close lie
|
||||
//! within the prior body.
|
||||
//! - **Bearish** (`-1.0`): the prior candle is a large **bullish** body and the
|
||||
//! current is a contained Doji.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! A doji is a candle whose body is `<= 0.1 * range`. The two-bar lookback means
|
||||
//! the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
fn is_doji(candle: Candle) -> bool {
|
||||
let body = (candle.close - candle.open).abs();
|
||||
let range = candle.high - candle.low;
|
||||
range > 0.0 && body <= 0.1 * range
|
||||
}
|
||||
|
||||
/// Harami Cross — large-body-then-contained-doji reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HaramiCross {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl HaramiCross {
|
||||
/// Construct a new `HaramiCross`.
|
||||
#[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 HaramiCross {
|
||||
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 prev_body_low = prev.open.min(prev.close);
|
||||
let prev_body_high = prev.open.max(prev.close);
|
||||
let prev_is_solid = !is_doji(prev);
|
||||
let curr_is_doji = is_doji(candle);
|
||||
let contained = candle.open >= prev_body_low
|
||||
&& candle.open <= prev_body_high
|
||||
&& candle.close >= prev_body_low
|
||||
&& candle.close <= prev_body_high;
|
||||
|
||||
let v = if prev_is_solid && curr_is_doji && contained {
|
||||
if prev.close < prev.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 {
|
||||
"HaramiCross"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn solid(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.2,
|
||||
open.min(close) - 0.2,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn doji(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HaramiCross::new();
|
||||
assert_eq!(h.warmup_period(), 2);
|
||||
assert_eq!(h.name(), "HaramiCross");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut h = HaramiCross::new();
|
||||
assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
|
||||
assert!(h.update(doji(105.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_harami_cross() {
|
||||
// prior big bearish body [100, 110]; doji centred at 105 inside it -> +1.
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
assert_eq!(h.update(doji(105.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_harami_cross() {
|
||||
// prior big bullish body [100, 110]; doji inside -> -1.
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(100.0, 110.0));
|
||||
assert_eq!(h.update(doji(105.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_outside_body_is_zero() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
// doji centred at 120, outside the prior body -> 0.
|
||||
assert_eq!(h.update(doji(120.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_doji_second_is_zero() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
assert_eq!(h.update(solid(104.0, 106.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
h.update(doji(105.0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
if i % 2 == 0 {
|
||||
solid(110.0, 100.0)
|
||||
} else {
|
||||
doji(105.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let batch = HaramiCross::new().batch(&candles);
|
||||
let mut b = HaramiCross::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Hasbrouck Information Share — each venue's contribution to price discovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hasbrouck Information Share — the share of price-discovery attributable to the
|
||||
/// **first** of two synchronised price series (e.g. the same asset on two venues).
|
||||
///
|
||||
/// ```text
|
||||
/// rx_t = x_t − x_{t−1}, ry_t = y_t − y_{t−1} (one-step price changes)
|
||||
/// IS_x = var(rx) / ( var(rx) + var(ry) ) over the window, ∈ [0, 1]
|
||||
/// ```
|
||||
///
|
||||
/// When the same instrument trades on several venues, Joel Hasbrouck's information
|
||||
/// share measures how much each venue contributes to the common efficient price.
|
||||
/// The venue whose innovations carry more of the variance leads price discovery.
|
||||
/// This streaming form uses the **variance-ratio proxy**: the fraction of total
|
||||
/// return variance contributed by series `x`. A reading above `0.5` means venue
|
||||
/// `x` is the price leader; below `0.5`, the follower. (The full Hasbrouck measure
|
||||
/// estimates a vector error-correction model and reports an upper/lower bound from
|
||||
/// the Cholesky ordering; this proxy captures the leading idea without the VECM.)
|
||||
///
|
||||
/// The output is in `[0, 1]`; if both series are flat it reports the neutral `0.5`.
|
||||
/// The first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HasbrouckInformationShare};
|
||||
///
|
||||
/// let mut indicator = HasbrouckInformationShare::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // Venue x moves a lot, venue y barely moves -> x leads.
|
||||
/// let x = (f64::from(i) * 0.5).sin() * 10.0;
|
||||
/// let y = (f64::from(i) * 0.5).sin() * 1.0;
|
||||
/// last = indicator.update((x, y));
|
||||
/// }
|
||||
/// assert!(last.unwrap() > 0.8);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HasbrouckInformationShare {
|
||||
period: usize,
|
||||
prev: Option<(f64, f64)>,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_x: f64,
|
||||
sum_y: f64,
|
||||
sum_xx: f64,
|
||||
sum_yy: f64,
|
||||
}
|
||||
|
||||
impl HasbrouckInformationShare {
|
||||
/// Construct a Hasbrouck information share over `period` return pairs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs two
|
||||
/// returns).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "information share needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_x: 0.0,
|
||||
sum_y: 0.0,
|
||||
sum_xx: 0.0,
|
||||
sum_yy: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of return pairs.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HasbrouckInformationShare {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (x, y) = input;
|
||||
let Some((px, py)) = self.prev else {
|
||||
self.prev = Some((x, y));
|
||||
return None;
|
||||
};
|
||||
self.prev = Some((x, y));
|
||||
let (rx, ry) = (x - px, y - py);
|
||||
if self.window.len() == self.period {
|
||||
let (ox, oy) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_x -= ox;
|
||||
self.sum_y -= oy;
|
||||
self.sum_xx -= ox * ox;
|
||||
self.sum_yy -= oy * oy;
|
||||
}
|
||||
self.window.push_back((rx, ry));
|
||||
self.sum_x += rx;
|
||||
self.sum_y += ry;
|
||||
self.sum_xx += rx * rx;
|
||||
self.sum_yy += ry * ry;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let var_x = (self.sum_xx / n - (self.sum_x / n).powi(2)).max(0.0);
|
||||
let var_y = (self.sum_yy / n - (self.sum_y / n).powi(2)).max(0.0);
|
||||
let total = var_x + var_y;
|
||||
Some(if total > 0.0 { var_x / total } else { 0.5 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_x = 0.0;
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xx = 0.0;
|
||||
self.sum_yy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HasbrouckInformationShare"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
HasbrouckInformationShare::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(HasbrouckInformationShare::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HasbrouckInformationShare::new(20).unwrap();
|
||||
assert_eq!(h.period(), 20);
|
||||
assert_eq!(h.warmup_period(), 21);
|
||||
assert_eq!(h.name(), "HasbrouckInformationShare");
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_needs_period_plus_one() {
|
||||
let mut h = HasbrouckInformationShare::new(3).unwrap();
|
||||
assert_eq!(h.update((1.0, 1.0)), None);
|
||||
assert_eq!(h.update((2.0, 2.0)), None);
|
||||
assert_eq!(h.update((3.0, 2.5)), None);
|
||||
assert!(h.update((4.0, 3.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loud_venue_leads() {
|
||||
// x is far more volatile than y -> x holds nearly all the share.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|i| {
|
||||
(
|
||||
(f64::from(i) * 0.5).sin() * 10.0,
|
||||
(f64::from(i) * 0.5).sin() * 1.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let last = HasbrouckInformationShare::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.8, "the loud venue should lead, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_venues_split_evenly() {
|
||||
// Independent but equal-variance moves -> share near 0.5.
|
||||
let pairs: Vec<(f64, f64)> = (0..200)
|
||||
.map(|i| {
|
||||
(
|
||||
(f64::from(i) * 0.5).sin() * 5.0,
|
||||
(f64::from(i) * 0.5).cos() * 5.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for v in HasbrouckInformationShare::new(40)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_series_is_half() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (7.0, 9.0)).collect();
|
||||
let last = HasbrouckInformationShare::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HasbrouckInformationShare::new(4).unwrap();
|
||||
h.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..120)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin() * 5.0, (t * 0.5).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
let batch = HasbrouckInformationShare::new(20).unwrap().batch(&pairs);
|
||||
let mut h = HasbrouckInformationShare::new(20).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Heikin-Ashi Oscillator — the (smoothed) Heikin-Ashi candle body as a zero-line oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::heikin_ashi::HeikinAshi;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Heikin-Ashi Oscillator — the body of the [`HeikinAshi`](crate::HeikinAshi)
|
||||
/// candle (`ha_close − ha_open`), optionally EMA-smoothed, as an oscillator around
|
||||
/// zero.
|
||||
///
|
||||
/// ```text
|
||||
/// body = ha_close − ha_open
|
||||
/// HAO = EMA(body, period)
|
||||
/// ```
|
||||
///
|
||||
/// A Heikin-Ashi candle is bullish when its close is above its open and bearish
|
||||
/// when below; the size of that body measures conviction. Plotting the body as an
|
||||
/// oscillator turns the visual HA colour/strength into a number: positive =
|
||||
/// bullish HA candles, negative = bearish, and the magnitude is trend strength.
|
||||
/// Smoothing the body with an EMA (`period`) damps single-bar noise so zero-line
|
||||
/// crosses mark cleaner trend changes. With `period == 1` the oscillator is the raw
|
||||
/// HA body.
|
||||
///
|
||||
/// The output is centred on zero (price units). The first value lands after
|
||||
/// `period` inputs (the HA transform itself needs only one). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, HeikinAshiOscillator};
|
||||
///
|
||||
/// let mut indicator = HeikinAshiOscillator::new(5).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 + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeikinAshiOscillator {
|
||||
period: usize,
|
||||
ha: HeikinAshi,
|
||||
ema: Ema,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HeikinAshiOscillator {
|
||||
/// Construct a Heikin-Ashi Oscillator with the given EMA smoothing `period`
|
||||
/// (use `1` for the raw body).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
ha: HeikinAshi::new(),
|
||||
ema: Ema::new(period)?,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeikinAshiOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ha = self.ha.update(candle).expect("HeikinAshi emits every bar");
|
||||
let body = ha.close - ha.open;
|
||||
let v = self.ema.update(body)?;
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ha.reset();
|
||||
self.ema.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HeikinAshiOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(open, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
HeikinAshiOscillator::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HeikinAshiOscillator::new(5).unwrap();
|
||||
assert_eq!(h.period(), 5);
|
||||
assert_eq!(h.warmup_period(), 5);
|
||||
assert_eq!(h.name(), "HeikinAshiOscillator");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = h.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 1.5)
|
||||
})
|
||||
.collect();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.0,
|
||||
"uptrend should give a positive HA body, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 200.0 - 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b - 1.5)
|
||||
})
|
||||
.collect();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"downtrend should give a negative HA body, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_near_zero() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let last = h
|
||||
.batch(&[c(100.0, 100.5, 99.5, 100.0); 30])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
h.batch(
|
||||
&(0..10)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
assert_eq!(h.update(c(100.0, 101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = HeikinAshiOscillator::new(5).unwrap().batch(&candles);
|
||||
let mut b = HeikinAshiOscillator::new(5).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price
|
||||
/// series, leaving the higher-frequency cyclic and noise content.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// a = 0.707 · 2π / period
|
||||
/// alpha1 = (cos(a) + sin(a) − 1) / cos(a)
|
||||
/// HP_t = (1 − alpha1/2)² · (price_t − 2·price_{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);
|
||||
}
|
||||
}
|
||||
@@ -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,12 +42,14 @@ 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;
|
||||
@@ -62,8 +67,10 @@ mod butterfly;
|
||||
mod calendar_spread;
|
||||
mod calmar_ratio;
|
||||
mod camarilla_pivots;
|
||||
mod candle_volume;
|
||||
mod cci;
|
||||
mod center_of_gravity;
|
||||
mod central_pivot_range;
|
||||
mod cfo;
|
||||
mod chaikin_oscillator;
|
||||
mod chaikin_volatility;
|
||||
@@ -81,6 +88,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;
|
||||
@@ -109,6 +117,7 @@ mod downside_gap_three_methods;
|
||||
mod dpo;
|
||||
mod dragonfly_doji;
|
||||
mod drawdown_duration;
|
||||
mod dumpling_top;
|
||||
mod dx;
|
||||
mod dynamic_momentum_index;
|
||||
mod ease_of_movement;
|
||||
@@ -121,6 +130,8 @@ mod elder_safezone;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod equivolume;
|
||||
mod even_better_sinewave;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
mod ewma_volatility;
|
||||
@@ -143,6 +154,7 @@ mod footprint;
|
||||
mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
mod frama;
|
||||
mod fry_pan_bottom;
|
||||
mod funding_basis;
|
||||
mod funding_rate;
|
||||
mod funding_rate_mean;
|
||||
@@ -161,11 +173,15 @@ mod gravestone_doji;
|
||||
mod hammer;
|
||||
mod hanging_man;
|
||||
mod harami;
|
||||
mod harami_cross;
|
||||
mod hasbrouck_information_share;
|
||||
mod head_and_shoulders;
|
||||
mod heikin_ashi;
|
||||
mod heikin_ashi_oscillator;
|
||||
mod high_low_index;
|
||||
mod high_low_range;
|
||||
mod high_wave;
|
||||
mod highpass_filter;
|
||||
mod hikkake;
|
||||
mod hikkake_modified;
|
||||
mod hilbert_dominant_cycle;
|
||||
@@ -250,8 +266,10 @@ 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 new_price_lines;
|
||||
mod nrtr;
|
||||
mod nvi;
|
||||
mod ob_imbalance_full;
|
||||
@@ -279,6 +297,8 @@ mod percent_b;
|
||||
mod percentage_trailing_stop;
|
||||
mod pgo;
|
||||
mod piercing_dark_cloud;
|
||||
mod pin;
|
||||
mod pivot_reversal;
|
||||
mod plus_di;
|
||||
mod plus_dm;
|
||||
mod pmo;
|
||||
@@ -300,6 +320,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;
|
||||
@@ -344,6 +365,7 @@ mod skewness;
|
||||
mod sma;
|
||||
mod smi;
|
||||
mod smma;
|
||||
mod smoothed_heikin_ashi;
|
||||
mod sortino_ratio;
|
||||
mod spearman_correlation;
|
||||
mod spinning_top;
|
||||
@@ -367,22 +389,30 @@ 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;
|
||||
mod three_inside;
|
||||
mod three_line_break;
|
||||
mod three_line_strike;
|
||||
mod three_outside;
|
||||
mod three_soldiers_or_crows;
|
||||
@@ -392,16 +422,20 @@ mod tick_index;
|
||||
mod tii;
|
||||
mod time_based_stop;
|
||||
mod time_of_day_return_profile;
|
||||
mod tower_top_bottom;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trade_sign_autocorrelation;
|
||||
mod trade_volume_index;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
mod trendflex;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
mod trin;
|
||||
mod triple_top_bottom;
|
||||
mod tristar;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsf;
|
||||
@@ -418,6 +452,7 @@ 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;
|
||||
@@ -436,6 +471,7 @@ mod volume_oscillator;
|
||||
mod volume_profile;
|
||||
mod volume_rsi;
|
||||
mod volume_weighted_macd;
|
||||
mod volume_weighted_sr;
|
||||
mod vortex;
|
||||
mod vpin;
|
||||
mod vpt;
|
||||
@@ -468,8 +504,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;
|
||||
@@ -482,6 +520,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;
|
||||
@@ -491,12 +530,14 @@ 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;
|
||||
@@ -514,8 +555,10 @@ pub use butterfly::Butterfly;
|
||||
pub use calendar_spread::CalendarSpread;
|
||||
pub use calmar_ratio::CalmarRatio;
|
||||
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
|
||||
pub use candle_volume::{CandleVolume, CandleVolumeOutput};
|
||||
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;
|
||||
@@ -533,6 +576,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;
|
||||
@@ -561,6 +605,7 @@ pub use downside_gap_three_methods::DownsideGapThreeMethods;
|
||||
pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
pub use drawdown_duration::DrawdownDuration;
|
||||
pub use dumpling_top::DumplingTop;
|
||||
pub use dx::Dx;
|
||||
pub use dynamic_momentum_index::DynamicMomentumIndex;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
@@ -573,6 +618,8 @@ pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use equivolume::{Equivolume, EquivolumeOutput};
|
||||
pub use even_better_sinewave::EvenBetterSinewave;
|
||||
pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
pub use ewma_volatility::EwmaVolatility;
|
||||
@@ -595,6 +642,7 @@ pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
pub use frama::Frama;
|
||||
pub use fry_pan_bottom::FryPanBottom;
|
||||
pub use funding_basis::FundingBasis;
|
||||
pub use funding_rate::FundingRate;
|
||||
pub use funding_rate_mean::FundingRateMean;
|
||||
@@ -613,11 +661,15 @@ pub use gravestone_doji::GravestoneDoji;
|
||||
pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use harami_cross::HaramiCross;
|
||||
pub use hasbrouck_information_share::HasbrouckInformationShare;
|
||||
pub use head_and_shoulders::HeadAndShoulders;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
|
||||
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;
|
||||
@@ -702,8 +754,10 @@ 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 new_price_lines::NewPriceLines;
|
||||
pub use nrtr::{Nrtr, NrtrOutput};
|
||||
pub use nvi::Nvi;
|
||||
pub use ob_imbalance_full::OrderBookImbalanceFull;
|
||||
@@ -731,6 +785,8 @@ pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use pgo::Pgo;
|
||||
pub use piercing_dark_cloud::PiercingDarkCloud;
|
||||
pub use pin::Pin;
|
||||
pub use pivot_reversal::PivotReversal;
|
||||
pub use plus_di::PlusDi;
|
||||
pub use plus_dm::PlusDm;
|
||||
pub use pmo::Pmo;
|
||||
@@ -752,6 +808,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};
|
||||
@@ -796,6 +853,7 @@ pub use skewness::Skewness;
|
||||
pub use sma::Sma;
|
||||
pub use smi::Smi;
|
||||
pub use smma::Smma;
|
||||
pub use smoothed_heikin_ashi::{SmoothedHeikinAshi, SmoothedHeikinAshiOutput};
|
||||
pub use sortino_ratio::SortinoRatio;
|
||||
pub use spearman_correlation::SpearmanCorrelation;
|
||||
pub use spinning_top::SpinningTop;
|
||||
@@ -819,22 +877,30 @@ 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;
|
||||
pub use three_inside::ThreeInside;
|
||||
pub use three_line_break::ThreeLineBreak;
|
||||
pub use three_line_strike::ThreeLineStrike;
|
||||
pub use three_outside::ThreeOutside;
|
||||
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
|
||||
@@ -844,16 +910,20 @@ pub use tick_index::TickIndex;
|
||||
pub use tii::Tii;
|
||||
pub use time_based_stop::TimeBasedStop;
|
||||
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
|
||||
pub use tower_top_bottom::TowerTopBottom;
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trade_sign_autocorrelation::TradeSignAutocorrelation;
|
||||
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;
|
||||
pub use trin::Trin;
|
||||
pub use triple_top_bottom::TripleTopBottom;
|
||||
pub use tristar::Tristar;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsf::Tsf;
|
||||
@@ -870,6 +940,7 @@ 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;
|
||||
@@ -888,6 +959,7 @@ pub use volume_oscillator::VolumeOscillator;
|
||||
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
|
||||
pub use volume_rsi::VolumeRsi;
|
||||
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
|
||||
pub use volume_weighted_sr::{VolumeWeightedSr, VolumeWeightedSrOutput};
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
pub use vpin::Vpin;
|
||||
pub use vpt::VolumePriceTrend;
|
||||
@@ -1230,6 +1302,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"EmpiricalModeDecomposition",
|
||||
"EhlersStochastic",
|
||||
"InstantaneousTrendline",
|
||||
"HighpassFilter",
|
||||
"Reflex",
|
||||
"Trendflex",
|
||||
"CorrelationTrendIndicator",
|
||||
"AdaptiveRsi",
|
||||
"UniversalOscillator",
|
||||
"AdaptiveCci",
|
||||
"BandpassFilter",
|
||||
"EvenBetterSinewave",
|
||||
"AutocorrelationPeriodogram",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1242,6 +1324,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"DemarkPivots",
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
"CentralPivotRange",
|
||||
"MurreyMathLines",
|
||||
"AndrewsPitchfork",
|
||||
"VolumeWeightedSr",
|
||||
"PivotReversal",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1259,9 +1346,27 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TdDifferential",
|
||||
"TdOpen",
|
||||
"TdRiskLevel",
|
||||
"TdCamouflage",
|
||||
"TdClop",
|
||||
"TdClopwin",
|
||||
"TdPropulsion",
|
||||
"TdTrap",
|
||||
"TdDWave",
|
||||
"TdMovingAverage",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Ichimoku & Charts",
|
||||
&[
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
"HeikinAshiOscillator",
|
||||
"ThreeLineBreak",
|
||||
"SmoothedHeikinAshi",
|
||||
"Equivolume",
|
||||
"CandleVolume",
|
||||
],
|
||||
),
|
||||
("Ichimoku & Charts", &["Ichimoku", "HeikinAshi"]),
|
||||
(
|
||||
"Candlestick Patterns",
|
||||
&[
|
||||
@@ -1325,6 +1430,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"TasukiGap",
|
||||
"UniqueThreeRiver",
|
||||
"ConcealingBabySwallow",
|
||||
"Tristar",
|
||||
"HaramiCross",
|
||||
"TowerTopBottom",
|
||||
"FryPanBottom",
|
||||
"DumplingTop",
|
||||
"NewPriceLines",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1347,6 +1458,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Vpin",
|
||||
"AmihudIlliquidity",
|
||||
"RollMeasure",
|
||||
"TradeSignAutocorrelation",
|
||||
"Pin",
|
||||
"HasbrouckInformationShare",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1510,6 +1624,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, 452, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 488, "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,234 @@
|
||||
//! New Price Lines — the "eight/ten new price lines" exhaustion count.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// New Price Lines — the Japanese "shinne" (new-price) exhaustion count: when the
|
||||
/// close has made `count` consecutive new highs (or lows), the trend is considered
|
||||
/// stretched and ripe for a pause or reversal.
|
||||
///
|
||||
/// ```text
|
||||
/// consecutive higher closes form "new price lines" up
|
||||
/// consecutive lower closes form "new price lines" down
|
||||
/// signal = −1 once `count` consecutive higher closes (overbought / sell warning)
|
||||
/// signal = +1 once `count` consecutive lower closes (oversold / buy warning)
|
||||
/// signal = 0 otherwise
|
||||
/// ```
|
||||
///
|
||||
/// Traditional Japanese practice flags **eight** new price lines (and a stronger
|
||||
/// **ten** or twelve) as the point where a directional run becomes exhausted —
|
||||
/// the market has gone up (or down) so many bars in a row that a corrective pause
|
||||
/// is statistically due. The signal stays active for every bar the streak remains
|
||||
/// at or above `count`, and clears the moment a close breaks the streak.
|
||||
///
|
||||
/// The first value lands on the second bar (one prior close is needed). The
|
||||
/// output is `+1` / `0` / `−1`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, NewPriceLines};
|
||||
///
|
||||
/// let mut indicator = NewPriceLines::new(8).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..12 {
|
||||
/// let close = 100.0 + f64::from(i); // 11 consecutive higher closes
|
||||
/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewPriceLines {
|
||||
count: usize,
|
||||
prev_close: Option<f64>,
|
||||
consec_up: usize,
|
||||
consec_down: usize,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl NewPriceLines {
|
||||
/// Construct a New Price Lines counter that fires at `count` consecutive new
|
||||
/// closes (classic `8`, stronger `10`/`12`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `count < 2`.
|
||||
pub fn new(count: usize) -> Result<Self> {
|
||||
if count < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "new price lines count must be >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
count,
|
||||
prev_close: None,
|
||||
consec_up: 0,
|
||||
consec_down: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured count threshold.
|
||||
pub const fn count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Current consecutive streak `(up, down)`.
|
||||
pub const fn streak(&self) -> (usize, usize) {
|
||||
(self.consec_up, self.consec_down)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for NewPriceLines {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let close = candle.close;
|
||||
let Some(prev) = self.prev_close else {
|
||||
self.prev_close = Some(close);
|
||||
return None;
|
||||
};
|
||||
if close > prev {
|
||||
self.consec_up += 1;
|
||||
self.consec_down = 0;
|
||||
} else if close < prev {
|
||||
self.consec_down += 1;
|
||||
self.consec_up = 0;
|
||||
} else {
|
||||
self.consec_up = 0;
|
||||
self.consec_down = 0;
|
||||
}
|
||||
self.prev_close = Some(close);
|
||||
|
||||
let v = if self.consec_up >= self.count {
|
||||
-1.0
|
||||
} else if self.consec_down >= self.count {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.consec_up = 0;
|
||||
self.consec_down = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"NewPriceLines"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_count() {
|
||||
assert!(matches!(
|
||||
NewPriceLines::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(NewPriceLines::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let n = NewPriceLines::new(8).unwrap();
|
||||
assert_eq!(n.count(), 8);
|
||||
assert_eq!(n.streak(), (0, 0));
|
||||
assert_eq!(n.warmup_period(), 2);
|
||||
assert_eq!(n.name(), "NewPriceLines");
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
assert_eq!(n.update(c(100.0)), None);
|
||||
assert!(n.update(c(101.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_higher_closes_signal_sell() {
|
||||
let mut n = NewPriceLines::new(8).unwrap();
|
||||
// 11 consecutive higher closes -> by the 9th the count reaches 8 -> -1.
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let last = n.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_lower_closes_signal_buy() {
|
||||
let mut n = NewPriceLines::new(8).unwrap();
|
||||
let candles: Vec<Candle> = (0..12).map(|i| c(200.0 - f64::from(i))).collect();
|
||||
let last = n.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_in_streak_clears_signal() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // streak 3 -> -1
|
||||
assert_eq!(n.value(), Some(-1.0));
|
||||
// A lower close breaks the up streak.
|
||||
assert_eq!(n.update(c(102.0)), Some(0.0));
|
||||
assert_eq!(n.streak(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_close_resets_streak() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
n.batch(&[c(100.0), c(101.0), c(102.0)]);
|
||||
assert_eq!(n.update(c(102.0)), Some(0.0)); // equal -> reset
|
||||
assert_eq!(n.streak(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut n = NewPriceLines::new(3).unwrap();
|
||||
n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]);
|
||||
assert!(n.is_ready());
|
||||
n.reset();
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
assert_eq!(n.streak(), (0, 0));
|
||||
}
|
||||
|
||||
#[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 = NewPriceLines::new(8).unwrap().batch(&candles);
|
||||
let mut b = NewPriceLines::new(8).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! PIN — Probability of Informed Trading (single-window EKOP estimate).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// PIN — the **Probability of Informed Trading**, estimated from the buy/sell order
|
||||
/// imbalance over a rolling window of trades.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `window` trades: B = buys, S = sells (B + S = window)
|
||||
/// PIN ≈ |B − S| / (B + S) ∈ [0, 1]
|
||||
/// ```
|
||||
///
|
||||
/// The Easley-Kiefer-O'Hara-Paperman (EKOP) model splits order flow into an
|
||||
/// uninformed component (balanced buys and sells, rate `ε` per side) and an
|
||||
/// informed component that trades one-directionally when private information
|
||||
/// arrives (rate `μ`, probability `α`). The probability that any given trade is
|
||||
/// information-motivated is `PIN = αμ / (αμ + 2ε)`. Estimated over a single window,
|
||||
/// the informed flow shows up as the **net imbalance** `|B − S|` and the uninformed
|
||||
/// flow as the balanced remainder, giving the moment estimator above. A high PIN
|
||||
/// flags a one-sided, likely-informed market; a low PIN flags balanced, uninformed
|
||||
/// flow.
|
||||
///
|
||||
/// This is distinct from [`Vpin`](crate::Vpin), the volume-synchronised variant
|
||||
/// that buckets by volume and uses bulk-volume classification; here trades are
|
||||
/// counted in event time and classified by their tagged aggressor side. The full
|
||||
/// PIN is fit by maximum likelihood over many periods — this single-window
|
||||
/// estimator is the streaming moment approximation. The output is in `[0, 1]`; the
|
||||
/// first value lands after `window` trades.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Pin, Side, Trade};
|
||||
///
|
||||
/// let mut indicator = Pin::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // All buys -> maximally one-sided -> PIN 1.
|
||||
/// last = indicator.update(Trade::new(100.0, 1.0, Side::Buy, i).unwrap());
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pin {
|
||||
window: usize,
|
||||
sides: VecDeque<f64>,
|
||||
buy_count: usize,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
/// Construct a PIN estimator over `window` trades.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `window == 0`.
|
||||
pub fn new(window: usize) -> Result<Self> {
|
||||
if window == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
window,
|
||||
sides: VecDeque::with_capacity(window),
|
||||
buy_count: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of trades.
|
||||
pub const fn window(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Pin {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
let is_buy = trade.side.sign() > 0.0;
|
||||
if self.sides.len() == self.window {
|
||||
let old = self.sides.pop_front().expect("non-empty");
|
||||
if old > 0.0 {
|
||||
self.buy_count -= 1;
|
||||
}
|
||||
}
|
||||
self.sides.push_back(if is_buy { 1.0 } else { 0.0 });
|
||||
if is_buy {
|
||||
self.buy_count += 1;
|
||||
}
|
||||
if self.sides.len() < self.window {
|
||||
return None;
|
||||
}
|
||||
// The window is full and `window >= 1` (zero is rejected at
|
||||
// construction), so the trade count is always positive — `|B - S| / N`
|
||||
// needs no zero guard.
|
||||
let buys = self.buy_count as f64;
|
||||
let sells = self.window as f64 - buys;
|
||||
let total = self.window as f64;
|
||||
let pin = (buys - sells).abs() / total;
|
||||
self.last = Some(pin);
|
||||
Some(pin)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sides.clear();
|
||||
self.buy_count = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PIN"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn buy() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
|
||||
}
|
||||
|
||||
fn sell() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_window() {
|
||||
assert!(matches!(Pin::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = Pin::new(20).unwrap();
|
||||
assert_eq!(p.window(), 20);
|
||||
assert_eq!(p.warmup_period(), 20);
|
||||
assert_eq!(p.name(), "PIN");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = Pin::new(4).unwrap();
|
||||
let out = p.batch(&[buy(), buy(), buy(), buy(), buy()]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_flow_is_one() {
|
||||
let mut p = Pin::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
|
||||
let last = p.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_flow_is_zero() {
|
||||
let mut p = Pin::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { buy() } else { sell() })
|
||||
.collect();
|
||||
let last = p.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut p = Pin::new(16).unwrap();
|
||||
let trades: Vec<Trade> = (0..200)
|
||||
.map(|i| if (i * 5 % 13) < 8 { buy() } else { sell() })
|
||||
.collect();
|
||||
for v in p.batch(&trades).into_iter().flatten() {
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = Pin::new(4).unwrap();
|
||||
p.batch(&[buy(), buy(), sell(), buy()]);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.update(buy()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..120)
|
||||
.map(|i| if i % 3 == 0 { sell() } else { buy() })
|
||||
.collect();
|
||||
let batch = Pin::new(16).unwrap().batch(&trades);
|
||||
let mut b = Pin::new(16).unwrap();
|
||||
let streamed: Vec<_> = trades.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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,252 @@
|
||||
//! Smoothed Heikin-Ashi — Heikin-Ashi computed on EMA-smoothed OHLC.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One smoothed Heikin-Ashi candle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SmoothedHeikinAshiOutput {
|
||||
/// Smoothed Heikin-Ashi open.
|
||||
pub open: f64,
|
||||
/// Smoothed Heikin-Ashi high.
|
||||
pub high: f64,
|
||||
/// Smoothed Heikin-Ashi low.
|
||||
pub low: f64,
|
||||
/// Smoothed Heikin-Ashi close.
|
||||
pub close: f64,
|
||||
}
|
||||
|
||||
/// Smoothed Heikin-Ashi — the [`HeikinAshi`](crate::HeikinAshi) transform applied
|
||||
/// to **EMA-smoothed** OHLC, for an even cleaner trend view.
|
||||
///
|
||||
/// ```text
|
||||
/// eo, eh, el, ec = EMA(open|high|low|close, period)
|
||||
/// ha_close = (eo + eh + el + ec) / 4
|
||||
/// ha_open = (prev_ha_open + prev_ha_close) / 2 (seeded with (eo + ec)/2)
|
||||
/// ha_high = max(eh, ha_open, ha_close)
|
||||
/// ha_low = min(el, ha_open, ha_close)
|
||||
/// ```
|
||||
///
|
||||
/// Standard Heikin-Ashi already averages the OHLC; smoothing each input series
|
||||
/// with an EMA *before* the transform removes still more noise, producing long,
|
||||
/// uninterrupted runs of same-colour candles in a trend and crisp colour flips at
|
||||
/// turns. The trade-off is added lag proportional to `period`. The output uses the
|
||||
/// same OHLC field layout as a candle so it can be charted directly.
|
||||
///
|
||||
/// The first value lands once the EMAs are seeded (`period` inputs). Each `update`
|
||||
/// is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, SmoothedHeikinAshi};
|
||||
///
|
||||
/// let mut indicator = SmoothedHeikinAshi::new(10).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 + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmoothedHeikinAshi {
|
||||
period: usize,
|
||||
ema_open: Ema,
|
||||
ema_high: Ema,
|
||||
ema_low: Ema,
|
||||
ema_close: Ema,
|
||||
prev: Option<SmoothedHeikinAshiOutput>,
|
||||
last: Option<SmoothedHeikinAshiOutput>,
|
||||
}
|
||||
|
||||
impl SmoothedHeikinAshi {
|
||||
/// Construct a smoothed Heikin-Ashi with the given EMA `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,
|
||||
ema_open: Ema::new(period)?,
|
||||
ema_high: Ema::new(period)?,
|
||||
ema_low: Ema::new(period)?,
|
||||
ema_close: Ema::new(period)?,
|
||||
prev: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<SmoothedHeikinAshiOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SmoothedHeikinAshi {
|
||||
type Input = Candle;
|
||||
type Output = SmoothedHeikinAshiOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<SmoothedHeikinAshiOutput> {
|
||||
let eo = self.ema_open.update(candle.open);
|
||||
let eh = self.ema_high.update(candle.high);
|
||||
let el = self.ema_low.update(candle.low);
|
||||
let ec = self.ema_close.update(candle.close);
|
||||
let (Some(eo), Some(eh), Some(el), Some(ec)) = (eo, eh, el, ec) else {
|
||||
return None;
|
||||
};
|
||||
let ha_close = (eo + eh + el + ec) / 4.0;
|
||||
let ha_open = match self.prev {
|
||||
Some(p) => f64::midpoint(p.open, p.close),
|
||||
None => f64::midpoint(eo, ec),
|
||||
};
|
||||
let ha_high = eh.max(ha_open).max(ha_close);
|
||||
let ha_low = el.min(ha_open).min(ha_close);
|
||||
let out = SmoothedHeikinAshiOutput {
|
||||
open: ha_open,
|
||||
high: ha_high,
|
||||
low: ha_low,
|
||||
close: ha_close,
|
||||
};
|
||||
self.prev = Some(out);
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema_open.reset();
|
||||
self.ema_high.reset();
|
||||
self.ema_low.reset();
|
||||
self.ema_close.reset();
|
||||
self.prev = 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 {
|
||||
"SmoothedHeikinAshi"
|
||||
}
|
||||
}
|
||||
|
||||
#[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, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(SmoothedHeikinAshi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = SmoothedHeikinAshi::new(10).unwrap();
|
||||
assert_eq!(s.period(), 10);
|
||||
assert_eq!(s.warmup_period(), 10);
|
||||
assert_eq!(s.name(), "SmoothedHeikinAshi");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = s.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_brackets_open_close() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 2.0, b - 2.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
for o in s.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.high >= o.open && o.high >= o.close);
|
||||
assert!(o.low <= o.open && o.low <= o.close);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_close_above_open() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let o = s.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
o.close > o.open,
|
||||
"an uptrend should print a bullish smoothed HA candle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = SmoothedHeikinAshi::new(3).unwrap();
|
||||
s.batch(
|
||||
&(0..10)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(c(100.0, 101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = SmoothedHeikinAshi::new(10).unwrap().batch(&candles);
|
||||
let mut b = SmoothedHeikinAshi::new(10).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -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,241 @@
|
||||
//! Three Line Break — the close-driven line-break chart trend, as a direction.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Three Line Break — the trend direction of a line-break ("kakushi") chart, where
|
||||
/// a reversal requires the close to break the extreme of the last `lines` lines.
|
||||
///
|
||||
/// ```text
|
||||
/// continue the trend when close exceeds the prior line's end
|
||||
/// reverse the trend when close breaks beyond the extreme of the last `lines` lines
|
||||
/// output = current line direction: +1 (up), −1 (down)
|
||||
/// ```
|
||||
///
|
||||
/// A line-break chart ignores time and small moves entirely: it draws a new line
|
||||
/// only when the close makes a new extreme in the trend, and flips direction only
|
||||
/// when the close reverses past the high (or low) of the last `lines` lines —
|
||||
/// classically **three**. This filters out minor pullbacks, so the emitted
|
||||
/// direction stays in a trend until a genuinely significant reversal. Distinct from
|
||||
/// the candlestick [`ThreeLineStrike`](crate::ThreeLineStrike) (a fixed four-bar
|
||||
/// pattern); this is the line-break *chart type* reduced to its trend state. See
|
||||
/// also the alt-chart "Three-Line-Break Bars" builder.
|
||||
///
|
||||
/// The output is `+1.0` / `−1.0`. The first bar seeds the reference price; the
|
||||
/// direction is emitted once the first line is drawn (data-dependent;
|
||||
/// `warmup_period` returns the minimum `2`). Each `update` is O(`lines`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ThreeLineBreak};
|
||||
///
|
||||
/// let mut indicator = ThreeLineBreak::new(3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let close = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreeLineBreak {
|
||||
lines: usize,
|
||||
line_values: Vec<f64>,
|
||||
dir: i8,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl ThreeLineBreak {
|
||||
/// Construct a Three Line Break requiring `lines` lines to reverse (classic 3).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `lines == 0`.
|
||||
pub fn new(lines: usize) -> Result<Self> {
|
||||
if lines == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
lines,
|
||||
line_values: Vec::with_capacity(lines + 1),
|
||||
dir: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured number of lines required to reverse.
|
||||
pub const fn lines(&self) -> usize {
|
||||
self.lines
|
||||
}
|
||||
|
||||
/// Current direction if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn push_line(&mut self, close: f64, dir: i8) {
|
||||
self.dir = dir;
|
||||
self.line_values.push(close);
|
||||
if self.line_values.len() > self.lines {
|
||||
self.line_values.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ThreeLineBreak {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let close = candle.close;
|
||||
let Some(&prior) = self.line_values.last() else {
|
||||
// Seed the reference price; no line yet.
|
||||
self.line_values.push(close);
|
||||
return None;
|
||||
};
|
||||
if self.dir >= 0 {
|
||||
if close > prior {
|
||||
self.push_line(close, 1);
|
||||
} else {
|
||||
let low = self
|
||||
.line_values
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
if close < low {
|
||||
self.push_line(close, -1);
|
||||
}
|
||||
}
|
||||
} else if close < prior {
|
||||
self.push_line(close, -1);
|
||||
} else {
|
||||
let high = self
|
||||
.line_values
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
if close > high {
|
||||
self.push_line(close, 1);
|
||||
}
|
||||
}
|
||||
if self.dir == 0 {
|
||||
return None;
|
||||
}
|
||||
let v = f64::from(self.dir);
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.line_values.clear();
|
||||
self.dir = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ThreeLineBreak"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_lines() {
|
||||
assert!(matches!(ThreeLineBreak::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = ThreeLineBreak::new(3).unwrap();
|
||||
assert_eq!(t.lines(), 3);
|
||||
assert_eq!(t.warmup_period(), 2);
|
||||
assert_eq!(t.name(), "ThreeLineBreak");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_plus_one() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let out = t.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert_eq!(out[1], Some(1.0));
|
||||
assert_eq!(out.last().unwrap(), &Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_minus_one() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| c(100.0 - f64::from(i))).collect();
|
||||
let last = t.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pullback_does_not_reverse() {
|
||||
// Rise to build 3 up-lines, then a small dip that does not break the
|
||||
// 3-line low keeps the direction up.
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // up-lines at 101,102,103
|
||||
// close 102.5 is below the prior line (103) but above the 3-line low (101) -> no reversal.
|
||||
assert_eq!(t.update(c(102.5)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_of_three_line_extreme_reverses() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
t.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // lines 101,102,103, dir up
|
||||
// close 100.5 breaks below the 3-line low (101) -> reverse to down.
|
||||
assert_eq!(t.update(c(100.5)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
t.batch(&(0..10).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
assert_eq!(t.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_close_emits_none_until_a_line_forms() {
|
||||
let mut t = ThreeLineBreak::new(3).unwrap();
|
||||
assert_eq!(t.update(c(100.0)), None);
|
||||
// An identical close draws no line, so the direction stays unset.
|
||||
assert_eq!(t.update(c(100.0)), None);
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[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 = ThreeLineBreak::new(3).unwrap().batch(&candles);
|
||||
let mut b = ThreeLineBreak::new(3).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tower Top / Tower Bottom — a tall bar, a pause, then a tall opposite bar.
|
||||
//!
|
||||
//! A Tower is a reversal where a strong directional bar is followed by a small
|
||||
//! "pause" bar and then a strong bar in the *opposite* direction, like two towers
|
||||
//! flanking a low wall. This is the compact three-bar form of the classic
|
||||
//! multi-bar Tower pattern.
|
||||
//!
|
||||
//! - **Tower Bottom** (`+1.0`): a tall **bearish** bar, a small-bodied bar, then a
|
||||
//! tall **bullish** bar.
|
||||
//! - **Tower Top** (`-1.0`): a tall **bullish** bar, a small-bodied bar, then a
|
||||
//! tall **bearish** bar.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! "Tall" = body `>= 0.5 * range`; "small" = body `<= 0.3 * range`. The three-bar
|
||||
//! lookback means the first value lands on the third candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
fn body_fraction(candle: Candle) -> f64 {
|
||||
let range = candle.high - candle.low;
|
||||
if range > 0.0 {
|
||||
(candle.close - candle.open).abs() / range
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tall(candle: Candle) -> bool {
|
||||
body_fraction(candle) >= 0.5
|
||||
}
|
||||
|
||||
fn is_small(candle: Candle) -> bool {
|
||||
body_fraction(candle) <= 0.3
|
||||
}
|
||||
|
||||
/// Tower Top / Bottom — three-bar reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TowerTopBottom {
|
||||
c1: Option<Candle>,
|
||||
c2: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl TowerTopBottom {
|
||||
/// Construct a new `TowerTopBottom`.
|
||||
#[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 TowerTopBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let pause = is_small(middle);
|
||||
let first_tall = is_tall(first);
|
||||
let last_tall = is_tall(candle);
|
||||
let v = if pause && first_tall && last_tall {
|
||||
let first_up = first.close > first.open;
|
||||
let last_up = candle.close > candle.open;
|
||||
if !first_up && last_up {
|
||||
1.0
|
||||
} else if first_up && !last_up {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.c1 = None;
|
||||
self.c2 = 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 {
|
||||
"TowerTopBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
/// A tall candle from `open` to `close` (body fills most of the range).
|
||||
fn tall(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.1,
|
||||
open.min(close) - 0.1,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// A small-bodied candle (long shadows, tiny body).
|
||||
fn small(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TowerTopBottom::new();
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert_eq!(t.name(), "TowerTopBottom");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_seed_without_signal() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
|
||||
assert_eq!(t.update(small(105.0)), Some(0.0));
|
||||
assert!(t.update(tall(110.0, 100.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tower_top() {
|
||||
// tall bullish, small pause, tall bearish -> top -> -1.
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(small(110.0));
|
||||
assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tower_bottom() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(110.0, 100.0));
|
||||
t.update(small(100.0));
|
||||
assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_is_zero() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(small(110.0));
|
||||
// last bar also bullish -> not a tower -> 0.
|
||||
assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pause_is_zero() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(tall(110.0, 120.0)); // middle is tall, not a pause
|
||||
assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(small(110.0));
|
||||
t.update(tall(110.0, 100.0));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_bar_has_zero_body_fraction() {
|
||||
// A flat bar (high == low) exercises the zero-range body-fraction branch;
|
||||
// it counts as a small "pause" bar, so tall-flat-tall still reverses.
|
||||
fn flat(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0)
|
||||
}
|
||||
let mut t = TowerTopBottom::new();
|
||||
t.update(tall(100.0, 110.0));
|
||||
t.update(flat(110.0));
|
||||
assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| match i % 3 {
|
||||
0 => tall(100.0, 110.0),
|
||||
1 => small(110.0),
|
||||
_ => tall(110.0, 100.0),
|
||||
})
|
||||
.collect();
|
||||
let batch = TowerTopBottom::new().batch(&candles);
|
||||
let mut b = TowerTopBottom::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Trade-Sign Autocorrelation — lag-1 persistence of the trade-aggressor side.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trade-Sign Autocorrelation — the lag-1 autocorrelation of the **trade sign**
|
||||
/// (`+1` buy, `−1` sell), measuring how strongly signed order flow persists.
|
||||
///
|
||||
/// ```text
|
||||
/// s_t = +1 if the trade is a buy, −1 if a sell
|
||||
/// ρ1 = mean over the window of ( s_t · s_{t−1} ) ∈ [−1, +1]
|
||||
/// ```
|
||||
///
|
||||
/// In real markets trade signs are strongly **positively** autocorrelated: a buy
|
||||
/// tends to be followed by another buy (and a sell by a sell), because large
|
||||
/// parent orders are split into many child trades and because of order-splitting
|
||||
/// and herding. A high reading therefore indicates persistent directional pressure
|
||||
/// — a footprint of informed or algorithmic execution — while a reading near zero
|
||||
/// signals balanced, uninformed flow and a negative reading signals alternating
|
||||
/// (bid-ask bounce) flow.
|
||||
///
|
||||
/// The output is the mean product of consecutive signs, bounded in `[−1, +1]`. The
|
||||
/// first value lands after `period` trades. Each `update` is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Side, Trade, TradeSignAutocorrelation};
|
||||
///
|
||||
/// let mut indicator = TradeSignAutocorrelation::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
/// last = indicator.update(Trade::new(100.0, 1.0, side, i).unwrap());
|
||||
/// }
|
||||
/// // Perfectly alternating signs -> autocorrelation -1.
|
||||
/// assert!((last.unwrap() + 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TradeSignAutocorrelation {
|
||||
period: usize,
|
||||
signs: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl TradeSignAutocorrelation {
|
||||
/// Construct a trade-sign autocorrelation over `period` trades.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a lag-1 product needs two
|
||||
/// trades).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "trade-sign autocorrelation needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
signs: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of trades.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TradeSignAutocorrelation {
|
||||
type Input = Trade;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<f64> {
|
||||
if self.signs.len() == self.period {
|
||||
self.signs.pop_front();
|
||||
}
|
||||
self.signs.push_back(trade.side.sign());
|
||||
if self.signs.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut product_sum = 0.0;
|
||||
let mut prev: Option<f64> = None;
|
||||
for &s in &self.signs {
|
||||
if let Some(p) = prev {
|
||||
product_sum += s * p;
|
||||
}
|
||||
prev = Some(s);
|
||||
}
|
||||
let rho = product_sum / (self.period as f64 - 1.0);
|
||||
self.last = Some(rho);
|
||||
Some(rho)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.signs.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 {
|
||||
"TradeSignAutocorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn buy() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
|
||||
}
|
||||
|
||||
fn sell() -> Trade {
|
||||
Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
TradeSignAutocorrelation::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(TradeSignAutocorrelation::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TradeSignAutocorrelation::new(20).unwrap();
|
||||
assert_eq!(t.period(), 20);
|
||||
assert_eq!(t.warmup_period(), 20);
|
||||
assert_eq!(t.name(), "TradeSignAutocorrelation");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut t = TradeSignAutocorrelation::new(4).unwrap();
|
||||
let out = t.batch(&[buy(), buy(), buy(), buy(), buy()]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_flow_is_one() {
|
||||
let mut t = TradeSignAutocorrelation::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
|
||||
let last = t.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_flow_is_minus_one() {
|
||||
let mut t = TradeSignAutocorrelation::new(10).unwrap();
|
||||
let trades: Vec<Trade> = (0..20)
|
||||
.map(|i| if i % 2 == 0 { buy() } else { sell() })
|
||||
.collect();
|
||||
let last = t.batch(&trades).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut t = TradeSignAutocorrelation::new(16).unwrap();
|
||||
let trades: Vec<Trade> = (0..200)
|
||||
.map(|i| if (i * 7 % 13) < 6 { buy() } else { sell() })
|
||||
.collect();
|
||||
for v in t.batch(&trades).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = TradeSignAutocorrelation::new(4).unwrap();
|
||||
t.batch(&[buy(), buy(), buy(), buy()]);
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
assert_eq!(t.update(buy()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..120)
|
||||
.map(|i| if i % 3 == 0 { sell() } else { buy() })
|
||||
.collect();
|
||||
let batch = TradeSignAutocorrelation::new(16).unwrap().batch(&trades);
|
||||
let mut b = TradeSignAutocorrelation::new(16).unwrap();
|
||||
let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).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,189 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Tristar — a three-doji reversal pattern.
|
||||
//!
|
||||
//! A Tristar is three consecutive Doji candles where the middle one gaps away
|
||||
//! from its neighbours, forming a star. A bearish Tristar (top) has the middle
|
||||
//! doji sitting above the other two; a bullish Tristar (bottom) has it below.
|
||||
//!
|
||||
//! - **Bullish** (`+1.0`): three dojis, the middle doji's body centre below both
|
||||
//! neighbours' body centres.
|
||||
//! - **Bearish** (`-1.0`): three dojis, the middle above both neighbours.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! A doji is a candle whose body is `<= 0.1 * range`. The three-bar lookback means
|
||||
//! the first value lands on the third candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Body-centre of a candle.
|
||||
fn body_mid(candle: Candle) -> f64 {
|
||||
f64::midpoint(candle.open, candle.close)
|
||||
}
|
||||
|
||||
/// Whether a candle is a doji (body small relative to range).
|
||||
fn is_doji(candle: Candle) -> bool {
|
||||
let body = (candle.close - candle.open).abs();
|
||||
let range = candle.high - candle.low;
|
||||
range > 0.0 && body <= 0.1 * range
|
||||
}
|
||||
|
||||
/// Tristar — three-doji star reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Tristar {
|
||||
c1: Option<Candle>,
|
||||
c2: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Tristar {
|
||||
/// Construct a new `Tristar`.
|
||||
#[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 Tristar {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
|
||||
let mid = body_mid(middle);
|
||||
let n1 = body_mid(first);
|
||||
let n3 = body_mid(candle);
|
||||
if mid > n1 && mid > n3 {
|
||||
-1.0
|
||||
} else if mid < n1 && mid < n3 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.c1 = self.c2;
|
||||
self.c2 = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.c1 = None;
|
||||
self.c2 = 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 {
|
||||
"Tristar"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
/// A doji centred at `mid` (tiny body, symmetric shadows).
|
||||
fn doji(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
|
||||
}
|
||||
|
||||
/// A non-doji (big body).
|
||||
fn solid(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.1,
|
||||
open.min(close) - 0.1,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = Tristar::new();
|
||||
assert_eq!(t.warmup_period(), 3);
|
||||
assert_eq!(t.name(), "Tristar");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_two_bars_seed_without_signal() {
|
||||
let mut t = Tristar::new();
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
assert!(t.update(doji(100.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_tristar_top() {
|
||||
// middle doji centred above the two neighbours -> top -> -1.
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(doji(105.0)); // middle, highest
|
||||
assert_eq!(t.update(doji(100.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_tristar_bottom() {
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(doji(95.0)); // middle, lowest
|
||||
assert_eq!(t.update(doji(100.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_doji_is_zero() {
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(solid(100.0, 110.0)); // not a doji
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Tristar::new();
|
||||
t.update(doji(100.0));
|
||||
t.update(doji(105.0));
|
||||
t.update(doji(100.0));
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(doji(100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = Tristar::new().batch(&candles);
|
||||
let mut b = Tristar::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).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,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);
|
||||
}
|
||||
}
|
||||
@@ -57,42 +57,47 @@ 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, BetterVolume, BipowerVariation, BodySizePct, BollingerBands,
|
||||
BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway,
|
||||
BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput,
|
||||
Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
|
||||
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
|
||||
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
|
||||
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
|
||||
Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
|
||||
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, CandleVolume, CandleVolumeOutput, 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, 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,
|
||||
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, DumplingTop,
|
||||
Dx, DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma,
|
||||
ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema,
|
||||
EmpiricalModeDecomposition, Engulfing, Equivolume, EquivolumeOutput, 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, FryPanBottom, 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, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
HangingMan, Harami, HaramiCross, HasbrouckInformationShare, HeadAndShoulders, HeikinAshi,
|
||||
HeikinAshiOscillator, 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,
|
||||
@@ -105,43 +110,47 @@ pub use indicators::{
|
||||
MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
|
||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa,
|
||||
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop,
|
||||
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr,
|
||||
NrtrOutput, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta,
|
||||
OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap,
|
||||
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
|
||||
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars,
|
||||
PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, ProjectionBands,
|
||||
ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands,
|
||||
QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
|
||||
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
|
||||
RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi,
|
||||
Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, ShannonEntropy, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume,
|
||||
SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
|
||||
SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput,
|
||||
SpreadHurst, StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput,
|
||||
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi,
|
||||
Stochastic, StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
|
||||
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
|
||||
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
|
||||
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, MurreyMathLines,
|
||||
MurreyMathLinesOutput, Natr, NewHighsNewLows, NewPriceLines, 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,
|
||||
Pin, 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,
|
||||
SmoothedHeikinAshi, SmoothedHeikinAshiOutput, 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,
|
||||
ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth,
|
||||
Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput,
|
||||
TowerTopBottom, TpoProfile, TpoProfileOutput, TradeImbalance, TradeSignAutocorrelation,
|
||||
TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex, TreynorRatio, Triangle, Trima,
|
||||
Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
|
||||
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, 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, VolumeRsi, VolumeWeightedMacd,
|
||||
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
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,
|
||||
@@ -162,4 +171,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 **452 indicators** across
|
||||
- A per-indicator deep dive for every one of the **488 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.4",
|
||||
"version": "0.7.0",
|
||||
"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.4",
|
||||
"wickra-darwin-x64": "0.6.4",
|
||||
"wickra-linux-arm64-gnu": "0.6.4",
|
||||
"wickra-linux-x64-gnu": "0.6.4",
|
||||
"wickra-win32-arm64-msvc": "0.6.4",
|
||||
"wickra-win32-x64-msvc": "0.6.4"
|
||||
"wickra-darwin-arm64": "0.7.0",
|
||||
"wickra-darwin-x64": "0.7.0",
|
||||
"wickra-linux-arm64-gnu": "0.7.0",
|
||||
"wickra-linux-x64-gnu": "0.7.0",
|
||||
"wickra-win32-arm64-msvc": "0.7.0",
|
||||
"wickra-win32-x64-msvc": "0.7.0"
|
||||
}
|
||||
},
|
||||
"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, 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, 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, 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.
|
||||
@@ -179,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, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, CandleVolume, 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, DumplingTop, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, Equivolume, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, FryPanBottom, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator, 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, NewPriceLines, 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, SmoothedHeikinAshi, 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, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, Tristar, 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);
|
||||
@@ -314,6 +315,26 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
}
|
||||
|
||||
// --- Candlestick Patterns (family 14) ---
|
||||
drive(TowerTopBottom::new, &candles);
|
||||
drive(HaramiCross::new, &candles);
|
||||
drive(Tristar::new, &candles);
|
||||
drive(|| FryPanBottom::new(9).unwrap(), &candles);
|
||||
drive(|| DumplingTop::new(9).unwrap(), &candles);
|
||||
drive(|| NewPriceLines::new(5).unwrap(), &candles);
|
||||
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);
|
||||
|
||||
// --- Ichimoku & Charts ---
|
||||
drive(|| HeikinAshiOscillator::new(5).unwrap(), &candles);
|
||||
drive(|| ThreeLineBreak::new(3).unwrap(), &candles);
|
||||
drive(|| SmoothedHeikinAshi::new(5).unwrap(), &candles);
|
||||
drive(|| Equivolume::new(20).unwrap(), &candles);
|
||||
drive(|| CandleVolume::new(20).unwrap(), &candles);
|
||||
drive(ConcealingBabySwallow::new, &candles);
|
||||
drive(UniqueThreeRiver::new, &candles);
|
||||
drive(TasukiGap::new, &candles);
|
||||
@@ -432,4 +453,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, KendallTau, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadAr1Coefficient, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
|
||||
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, HasbrouckInformationShare, 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)])
|
||||
@@ -48,6 +48,7 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs);
|
||||
drive(|| SpreadAr1Coefficient::new(40).unwrap(), &pairs);
|
||||
drive(|| KendallTau::new(20).unwrap(), &pairs);
|
||||
drive(|| HasbrouckInformationShare::new(2).unwrap(), &pairs);
|
||||
|
||||
// Struct-output pair indicator: drive update + batch directly (the generic
|
||||
// `drive` above only covers `Output = f64`).
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! would reject — the indicators must never panic, streaming or batched.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AmihudIlliquidity, BatchExt, CumulativeVolumeDelta, Footprint, Indicator, RollMeasure, Side, SignedVolume, Trade, TradeImbalance, Vpin};
|
||||
use wickra_core::{AmihudIlliquidity, BatchExt, CumulativeVolumeDelta, Footprint, Indicator, Pin, RollMeasure, Side, SignedVolume, Trade, TradeImbalance, TradeSignAutocorrelation, Vpin};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, trades: &[Trade])
|
||||
@@ -43,6 +43,8 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(|| Vpin::new(8.0, 5).unwrap(), &trades);
|
||||
drive(|| AmihudIlliquidity::new(20).unwrap(), &trades);
|
||||
drive(|| RollMeasure::new(20).unwrap(), &trades);
|
||||
drive(|| TradeSignAutocorrelation::new(20).unwrap(), &trades);
|
||||
drive(|| Pin::new(20).unwrap(), &trades);
|
||||
|
||||
// Footprint emits a variable-length `FootprintOutput` rather than an `f64`,
|
||||
// so it is driven directly rather than through the scalar-output helper.
|
||||
|
||||
Reference in New Issue
Block a user