Compare commits

...

48 Commits

Author SHA1 Message Date
kingchenc acd7e8dc52 release: bump 0.6.7 -> 0.6.8 (#208)
Release 0.6.8 — ships the B13 Ichimoku & Charts indicators (479 total). Version-string bump only.
2026-06-08 01:50:32 +02:00
kingchenc ceaeb90a22 feat: add Ichimoku & Charts deepening (B13, 5 indicators) (#207)
B13 of the family-deepening roadmap — five alternative-chart indicators (474 -> 479), all in the **Ichimoku & Charts** family.

- **Smoothed Heikin-Ashi** (`candle -> struct {open, high, low, close}`) — a Heikin-Ashi candle computed from EMA-smoothed OHLC.
- **Heikin-Ashi Oscillator** (`candle -> f64`) — the HA body (`ha_close - ha_open`), optionally EMA-smoothed, as a zero-line oscillator.
- **Three Line Break** (`candle -> f64`) — line-break ("kakushi") chart trend direction; reverses only when the close breaks the extreme of the last N lines. Distinct from the candlestick `ThreeLineStrike`.
- **Equivolume** (`candle -> struct {height, width}`) — a box whose height is the bar range and width is volume-relative.
- **CandleVolume** (`candle -> struct {body, width}`) — a candle whose body is close-minus-open and width is volume-relative.

All bindings hand-written (3 struct-output + 2 candle-input-with-open / non-period-ctor). Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs counter (479) and CHANGELOG. Verified: core 3915 + doc 432, clippy clean, node 554, python 913.
2026-06-08 01:49:03 +02:00
kingchenc 57e26fb22f release: bump 0.6.6 -> 0.6.7 (#205)
Release 0.6.7 — ships the B12 DeMark indicators (474 total).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

("Up/Down Volume Ratio" already ships from A2.) All Candle input; the six scalar stops emit f64, VolumeWeightedMacd a {macd, signal, histogram} struct. Hand-written Python/Node/WASM bindings for the volume signature. Verified locally: 3620 core lib + 405 doc tests, clippy clean, 522 node tests, 865 pytest, counter 447.
2026-06-07 02:30:56 +02:00
kingchenc fc6f3d80c2 release: bump 0.6.1 -> 0.6.2 (#194)
Version bump for the **v0.6.2** release shipping the **B7 Trailing Stops** family (#193): 434 -> 440 indicators.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified locally: `cargo fmt`, `cargo test --workspace --all-features` (3413 core
+ bindings), `cargo clippy --workspace --all-targets --all-features -D warnings`,
Node build + 498 tests, and pytest all green.
2026-06-06 20:57:31 +02:00
kingchenc d2bc000892 release: bump 0.5.7 -> 0.5.8 (#185)
Version bump for the B4 price oscillators release (#184): `TsfOscillator`, `MacdHistogram`, `PpoHistogram` — 420 → 423 indicators.

Bumps workspace + bindings (Cargo.toml/lock, pyproject, node package.json + 6 platform manifests + lockfiles) and rolls CHANGELOG `[Unreleased]` into `[0.5.8]`.
2026-06-04 19:47:22 +02:00
kingchenc 1f4bf9e3a6 feat(core): B4 price oscillators (TsfOscillator, MacdHistogram, PpoHistogram) (#184)
Adds three **Price Oscillators** family indicators (420 → 423).

## Indicators

- **TsfOscillator** — `100·(close − TSF)/close`, the percentage gap of the close to the **one-bar-ahead** time-series forecast. Close-relative companion to `Cfo`, which measures the same gap against the regression value at the *current* bar; the two differ by exactly the slope term `100·b/close`.
- **MacdHistogram** — the standalone `macd − signal` bar of MACD exposed as a plain `f64` series.
- **PpoHistogram** — the Percentage Price Oscillator with its 9-period signal EMA and the resulting scale-free, zero-centered histogram (PPO itself only emits the line).

All three are scalar `f64` indicators wrapping existing, already-tested building blocks (`MacdIndicator`, `Ppo` + `Ema`, `Tsf`).

## Scope notes (VORAB-CHECK)

The B4 roadmap listed six items; three were dropped to avoid duplicates:
- *Forecast Oscillator* already ships as `Cfo`.
- *Derivative Oscillator* already ships (`DerivativeOscillator`, B2).
- *Detrended Synthetic Price* deferred — no citable formula distinct from the existing `Apo`/`Dpo`.

## Touchpoints

Core (`tsf_oscillator.rs`, `macd_histogram.rs`, `ppo_histogram.rs`) with full per-branch unit tests, `mod.rs`/`lib.rs`, python/node/wasm bindings (wasm via typed-arg macro, python/node hand-written for the multi-arg histograms), fuzz drivers, python reference + streaming-vs-batch tests, node factories, README family row + counter, CHANGELOG.

Local verify: `cargo test --workspace` green, `clippy -D warnings` clean, node 498 tests, full python suite green.
2026-06-04 19:36:43 +02:00
kingchenc d36d514f56 fix(core): re-export GatorOscillatorOutput & KasePermissionStochasticOutput (#183)
The two market-profile struct-output indicators from the B3 batch (`GatorOscillator`, `KasePermissionStochastic`) exposed their public output structs from their own modules but did not re-export them from `indicators` / the crate root — unlike every other struct-output indicator (`ElderRayOutput`, `AlligatorOutput`, `QqeOutput`, …).

That left `wickra::GatorOscillatorOutput` / `wickra::KasePermissionStochasticOutput` un-nameable, so Rust callers could not annotate or store the `update` result by type. Surfaced by the wickra-docs Rust-snippet-compile check on the B3 deep-dives.

Re-export both alongside their structs. Both names end in `Output`, so the indicator counter strips them — catalog count stays **420**.
2026-06-04 18:43:26 +02:00
kingchenc 6e0464930e release: bump 0.5.6 -> 0.5.7 (#182)
Version bump 0.5.6 → 0.5.7 for the **B3 — Trend & Directional** batch (#181):
seven new indicators (`Qstick`, `TtmTrend`, `TrendStrengthIndex`,
`PolarizedFractalEfficiency`, `WavePm`, `GatorOscillator`,
`KasePermissionStochastic`), catalog 413 → 420.

Bumps: workspace `Cargo.toml` + `Cargo.lock`, `bindings/python/pyproject.toml`,
`bindings/node/package.json` + the six `npm/*/package.json` platform manifests,
both `package-lock.json` files, and the `CHANGELOG.md` `[Unreleased]` → `[0.5.7]`
roll with compare URLs.
2026-06-04 18:06:57 +02:00
kingchenc 13bc801f89 feat(indicators): B3 Trend & Directional batch (413 -> 420) (#181)
Adds the **B3 — Trend & Directional** batch: seven new indicators, taking the
catalog from 413 to 420 (Trend & Directional family).

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `Qstick` | candle → f64 | Chande's SMA of the candle body (close − open) |
| `TtmTrend` | candle → f64 (±1) | John Carter close-vs-median-SMA trend filter |
| `TrendStrengthIndex` | f64 → f64 | signed r² of an OLS regression of price vs time |
| `PolarizedFractalEfficiency` | f64 → f64 | Hannula directional trend efficiency |
| `WavePm` | f64 → f64 | Kase variance-normalised peak-momentum statistic (reconstruction) |
| `GatorOscillator` | candle → struct | Bill Williams Alligator convergence/divergence histogram |
| `KasePermissionStochastic` | candle → struct | double-smoothed stochastic permission filter |

Note: the roadmap's "Directional Indicator +DI/−DI" item is already covered by
the existing standalone `PlusDi` / `MinusDi` / `Dx`, so it is intentionally not
re-added.

All touchpoints wired: core (every-branch unit tests), Python/Node/WASM
bindings, fuzz drivers, Python test registries + reference tests, Node
factories, README/CHANGELOG counters.

Local verify: `cargo test -p wickra-core` (lib 3389 + doc 378), `cargo clippy
--workspace --all-targets --all-features -- -D warnings`, node build + 495
tests, maturin + 815 pytest, counter 420 == 420.
2026-06-04 17:57:24 +02:00
kingchenc ac8f6acf08 release: bump 0.5.5 -> 0.5.6 (#180)
Release bump `0.5.5 → 0.5.6` for the Momentum Oscillators family deepening
(#179): ten new indicators (DisparityIndex, FisherRsi, Rmi, DerivativeOscillator,
Rsx, DynamicMomentumIndex, IntradayMomentumIndex, StochasticCci, ElderRay, Qqe),
counter now 413.

Version strings only across all manifests + lockfiles; CHANGELOG `[Unreleased]`
rolled to `[0.5.6] - 2026-06-04` with the new compare links.
2026-06-04 15:35:41 +02:00
kingchenc 4f81222aed Deepen Momentum Oscillators family with ten additions (#179)
Deepens the **Momentum Oscillators** family with ten widely-used oscillators
(403 → 413 indicators), the second batch of Part B (family deepening).

| Indicator | Binding | Input → Output |
|-----------|---------|----------------|
| `DisparityIndex` | `DisparityIndex` | scalar → scalar |
| `FisherRsi` | `FisherRSI` | scalar → scalar |
| `Rmi` | `RMI` | scalar (period, momentum) → scalar |
| `DerivativeOscillator` | `DerivativeOscillator` | scalar (4 periods) → scalar |
| `Rsx` | `RSX` | scalar → scalar |
| `DynamicMomentumIndex` | `DynamicMomentumIndex` | scalar → scalar |
| `IntradayMomentumIndex` | `IMI` | candle (open+close) → scalar |
| `StochasticCci` | `StochasticCCI` | candle → scalar |
| `ElderRay` | `ElderRay` | candle → struct (bull/bear) |
| `Qqe` | `QQE` | scalar → struct (rsi_ma/trailing) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`.

The single-period scalars use generated macro bindings; `Rmi` /
`DerivativeOscillator` use hand node/python bindings with the typed wasm macro;
`ElderRay`/`Qqe` use custom struct bindings; `IntradayMomentumIndex` uses custom
candle bindings carrying the open. Full coverage: core modules with per-branch
unit tests, mod/lib catalogue, FAMILIES + assert, README + docs counters,
CHANGELOG, all three bindings (regenerated `index.d.ts`/`index.js`), fuzz
drivers, and the python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3335 + doc 371),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (488), python `pytest` (802).
2026-06-04 15:26:17 +02:00
kingchenc 0d2acad28d release: bump 0.5.4 -> 0.5.5 (#178)
Release bump `0.5.4 → 0.5.5` for the Moving Averages family deepening
(#177): seven new indicators (`SineWeightedMa`, `GeometricMa`, `Ehma`,
`MedianMa`, `AdaptiveLaguerreFilter`, `GeneralizedDema`, `HoltWinters`),
counter now 403.

Version strings only across all manifests + lockfiles; CHANGELOG `[Unreleased]`
rolled to `[0.5.5] - 2026-06-04` with the new compare links.
2026-06-04 13:55:26 +02:00
kingchenc b228a70d7d Deepen Moving Averages family with seven additions (#177)
Deepens the **Moving Averages** family with seven widely-used variants
(396 → 403 indicators), the first batch of Part B (family deepening).

All are scalar `f64 → f64`:

| Indicator | Binding | Notes |
|-----------|---------|-------|
| `SineWeightedMa` | `SWMA` | symmetric half-cycle sine-weighted window |
| `GeometricMa` | `GMA` | rolling geometric mean (log-space average) |
| `Ehma` | `EHMA` | exponential Hull MA (Hull construction over EMAs) |
| `MedianMa` | `MedianMA` | rolling median, robust to single outliers |
| `AdaptiveLaguerreFilter` | `AdaptiveLaguerre` | Ehlers' adaptive Laguerre filter (median-of-normalised-error γ) |
| `GeneralizedDema` | `GD` | Tillson's volume-factor double EMA; `v=1` is DEMA, `v=0` is EMA |
| `HoltWinters` | `HoltWinters` | Holt's linear double exponential smoothing (level + trend) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`
(TA-Lib `LINEARREG`, the rolling least-squares endpoint).

The five single-period filters use the generated scalar macro bindings;
`GeneralizedDema` (period, v) and `HoltWinters` (alpha, beta) use hand-written
node/python bindings with the typed wasm macro (precedent `T3` / `Alma`).

Full coverage: core modules with per-branch unit tests (100% intent), mod/lib
catalogue, FAMILIES group + assert, README + docs counters, CHANGELOG, all three
bindings (regenerated `index.d.ts` / `index.js`), fuzz drivers, and the
python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3255 + doc 361),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (478), python `pytest` (791).
2026-06-04 13:44:51 +02:00
kingchenc 8dc7158912 release: bump 0.5.3 -> 0.5.4 (#176)
Version bump 0.5.3 -> 0.5.4 for the release that ships the 19 external-feature-coverage indicators (#175, 377 -> 396).

Bumped: Cargo workspace + wickra-core dep, Cargo.lock (cargo build), pyproject.toml, node package.json (+6 optionalDependencies), 6 npm platform package.json, both package-lock.json, CHANGELOG ([Unreleased] -> [0.5.4]).

fmt/test/clippy green locally.
2026-06-04 12:14:29 +02:00
kingchenc fcb221ec03 feat: add 19 indicators for external feature-extractor coverage (377 -> 396) (#175)
Adds 19 streaming indicators so an external trading-bot feature extractor can replace its hand-built features with native, batch/streaming-equivalent ones. Each is a real gap (verified against the existing catalogue), production-only, with full Python/Node/WASM bindings, fuzz drivers, and tests. Five commits, one per family group; counter 377 -> 396.

## What's added

**Price Statistics (6)** — `LogReturn`, `RealizedVolatility` (raw quadratic variation, the un-annualised counterpart to `HistoricalVolatility`), `RollingQuantile`, `RollingIqr`, `RollingPercentileRank`, `SpreadAr1Coefficient` (pairwise AR(1) rho of the spread; complements `OuHalfLife`).

**Price Action (4)** — `CloseVsOpen`, `BodySizePct`, `WickRatio`, `HighLowRange` (stateless per-bar OHLC transforms).

**Regime / Trend / Jump labels (3)** — `TrendLabel` (sign of the rolling OLS slope), `JumpIndicator` (return outliers vs trailing volatility, measured as deviation from the trailing mean so steady drift is not flagged), `RegimeLabel` (volatility-quantile regime split).

**Risk / Performance (2)** — `WinRate`, `Expectancy` (R-multiple).

**Microstructure (4)** — `OrderFlowImbalance` (Cont-Kukanov-Stoikov OFI), `Vpin`, `AmihudIlliquidity`, `RollMeasure`. These reuse the existing `OrderBook` / `Trade` inputs (no new input type).

## Intentionally NOT added (already present, would be duplicates)

- **Population skew / kurtosis** — `skewness.rs` / `kurtosis.rs` are already population moments (divisor n).
- **Hurst R/S** — `hurst_exponent.rs` already uses rescaled-range (R/S) analysis.
- **Queue Imbalance** — exactly `OrderBookImbalanceTop1` ((bidSize - askSize) / (bidSize + askSize)).

## Verification

`cargo test -p wickra-core` (lib 3187 + doc 354), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (471), python `pytest` (784). Counter consistent across `mod.rs`, lib block, README, and docs/README at 396.
2026-06-04 12:00:35 +02:00
kingchenc a93af60796 release: bump 0.5.2 -> 0.5.3 (#174)
Version bump publishing the **Fibonacci** family (10 tools across A5a + A5b, catalogue 377 indicators / twenty-four families):

`FibRetracement`, `FibExtension`, `FibProjection`, `AutoFib`, `GoldenPocket`, `FibConfluence`, `FibFan`, `FibArcs`, `FibChannel`, `FibTimeZones`.

Version strings + lockfiles only (Cargo.toml, pyproject.toml, package.json + 6 npm platform manifests, both package-lock.json, Cargo.lock); CHANGELOG `[0.5.3]` section + compare URLs.
2026-06-04 01:25:31 +02:00
kingchenc 5a1d607807 feat(indicators): A5b Fibonacci tools (geometric) (#172)
Completes the **Fibonacci** family with the four geometric/time tools (catalogue 373 -> 377). All extend the internal `pattern_swing` ZigZag tracker with a per-pivot bar index and a current-bar counter (additive — the chart/harmonic detectors are unaffected), and emit `Candle -> struct` outputs via custom Python/Node/WASM bindings.

| Tool | Output |
|------|--------|
| `FibFan` | three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels, extended to the current bar |
| `FibArcs` | semicircular retracement levels centred on the swing end, normalised by the leg's bar-width (chart-scale-free) |
| `FibChannel` | a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width |
| `FibTimeZones` | markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot |

The geometric tools are novel as streaming indicators; each normalises its geometry to the swing leg's bar-width so the output is chart-scale-free. Formulas are documented in each module and deep-dive.

Fully wired: core (100% unit-tested branches incl. the new `pattern_swing` bar tracking), Python/Node/WASM struct bindings, fuzz, reference + streaming-vs-batch tests.

Verification: `cargo test --workspace` green, clippy `-D warnings` clean, node 454 tests, python 768 tests.
2026-06-04 01:12:09 +02:00
kingchenc ea9da12d86 docs(governance): document continuity and succession plan (#173)
Add a Continuity and succession section to GOVERNANCE.md (trusted-contact emergency access to credentials enabling continuity within a week). Closes OpenSSF Silver access_continuity.
2026-06-04 01:09:52 +02:00
kingchenc 716eb40206 feat(indicators): A5a Fibonacci tools (price-level) (#171)
Adds the six price-level Fibonacci tools as a new **Fibonacci** family (catalogue 367 -> 373, twenty-four families). All build on the internal `pattern_swing` ZigZag tracker, are parameter-free (baked 5% swing threshold), and emit `Candle -> struct` outputs via custom Python/Node/WASM bindings.

| Tool | Output |
|------|--------|
| `FibRetracement` | seven levels (0/23.6/38.2/50/61.8/78.6/100%) of the last swing leg |
| `FibExtension` | five extension ratios (127.2/141.4/161.8/200/261.8%) projected beyond the leg |
| `FibProjection` | A-B-C measured-move target zone (61.8/100/161.8/261.8%) |
| `AutoFib` | retracement anchored on the dominant (largest-magnitude) recent leg |
| `GoldenPocket` | the 0.618-0.65 optimal-trade-entry band (low/mid/high) |
| `FibConfluence` | densest cluster of retracement levels across recent legs (price + strength) |

Fully wired: core (100% unit-tested branches), Python/Node/WASM struct bindings, fuzz driver, reference + streaming-vs-batch tests, README/docs counter. The four geometric/time tools (Fan, Arcs, Channel, Time Zones) follow in A5b.

Verification: `cargo test --workspace` green, clippy `-D warnings` clean, node 450 tests, python 760 tests.
2026-06-04 00:47:00 +02:00
kingchenc 8115d3b33d release: bump 0.5.1 -> 0.5.2 (#170)
Version bump to release the A4 Chart Patterns (#166) and Harmonic Patterns (#169) families (catalogue 351 -> 367).
2026-06-03 23:39:10 +02:00
kingchenc 4250ed99f4 feat(patterns): add the Harmonic Patterns family (8 XABCD detectors) (#169)
## Summary

Adds a new **Harmonic Patterns** indicator family (counter 359 → 367, families 22 → 23) — the second half of the A4 roadmap item, following the Chart Patterns family in #166.

Eight Fibonacci-ratio detectors built on the shared swing-pivot tracker (`indicators::pattern_swing`) plus two new helpers there — `xabcd` (reads the last five pivots as X-A-B-C-D) and `ratios_in` (checks a list of `(value, low, high)` Fibonacci windows in one expression, no multi-line `&&` coverage gaps). Each consumes candles and emits the uniform pattern sign convention — `+1.0` bullish (terminal point D a swing low), `-1.0` bearish (D a swing high), `0.0` otherwise, never `None`. Parameter-free, with the Fibonacci windows documented as constants per detector.

## Detectors

| Indicator | Defining ratio |
|-----------|----------------|
| `Abcd` | four-point AB=CD (BC retraces AB, CD ≈ AB) |
| `Gartley` | AD/XA ≈ 0.786 |
| `Butterfly` | AD/XA ∈ 1.27–1.618 (extended D) |
| `Bat` | AD/XA ≈ 0.886, shallow B |
| `Crab` | AD/XA ≈ 1.618 (deepest D) |
| `Shark` | expansion AB, AD/XA 0.886–1.13 |
| `Cypher` | BC on XA, CD/XC ≈ 0.786 |
| `ThreeDrives` | two symmetric extension drives |

## Touchpoints

Core modules + `FAMILIES` group/assert, crate root re-exports, Python/Node/WASM bindings via the candle-pattern macros (Node `index.d.ts`/`index.js` regenerated), the candle fuzz target (`// --- Harmonic Patterns ---` section), Python reference + `CANDLE_SCALAR` registry tests and the Node candle-scalar factory, README catalogue counter + banner cache-buster + family table row + family-count word, `docs/README.md` counter, and the changelog.

## Verification

- `cargo test -p wickra-core --lib` — 2966 passed
- `cargo test -p wickra-core --doc` — 335 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- Node `npm run build && npm test` — 444 passed
- Python `maturin develop --release` + `pytest` — 748 passed

Every detector branch is unit-tested, including a bullish and a bearish match per pattern to cover both output arms, plus an out-of-ratio non-match. Fibonacci windows use standard harmonic-trading ranges with documented tolerance bands.
2026-06-03 23:24:25 +02:00
kingchenc 995f119010 feat(patterns): add the Chart Patterns family (8 swing-based detectors) (#166)
## Summary

Adds a new **Chart Patterns** indicator family (counter 351 → 359, families 21 → 22), the first half of the A4 roadmap item (the harmonic patterns follow in a second PR).

All eight detectors are built on a shared, non-repainting swing-pivot tracker — the internal, **uncounted** `indicators::pattern_swing` module (declared `pub(crate) mod`, re-exported nowhere). Each consumes candles and emits the uniform pattern sign convention already used by the candlestick family — `+1.0` bullish / `-1.0` bearish / `0.0` otherwise, never `None`. They are parameter-free, baking the swing threshold (5%) and level tolerance (3%) in as documented constants, mirroring how candlestick patterns bake in their geometric thresholds.

## Detectors

| Indicator | Signal |
|-----------|--------|
| `DoubleTopBottom` | twin-peak / twin-trough reversal |
| `TripleTopBottom` | three matching extremes (stronger reversal) |
| `HeadAndShoulders` | central head + matching shoulders + flat neckline (and inverse) |
| `Triangle` | ascending (+1) / descending (-1) / symmetrical |
| `Wedge` | rising wedge (-1) / falling wedge (+1) |
| `FlagPennant` | shallow consolidation against a pole → continuation |
| `RectangleRange` | flat support/resistance mean-reversion |
| `CupAndHandle` | rounded base + shallow handle (and inverse) |

## Touchpoints

Core modules + `FAMILIES` group and assert, crate root re-exports, Python/Node/WASM bindings via the candle-pattern macros (Node `index.d.ts`/`index.js` regenerated), the candle fuzz target, Python reference + `CANDLE_SCALAR` registry tests and the Node candle-scalar factory, README catalogue counter + banner cache-buster + family table row + family-count word, `docs/README.md` counter, and the changelog.

## Verification

- `cargo test -p wickra-core --lib` — 2915 passed
- `cargo test -p wickra-core --doc` — 335 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- Node `npm run build && npm test` — 436 passed
- Python `maturin develop --release` + `pytest` — 732 passed

Every detector branch is unit-tested; multi-condition predicates were flattened to single-line precomputed booleans to keep patch coverage at 100%.
2026-06-03 22:55:36 +02:00
kingchenc 05d2e5dc61 ci(scorecard): pass a read-only PAT for the Branch-Protection check (#168)
Pass a read-only fine-grained PAT (SCORECARD_TOKEN) as repo_token so the OpenSSF Scorecard Branch-Protection check can read classic branch-protection rules instead of failing with an internal error.
2026-06-03 22:51:28 +02:00
kingchenc 404bcb040c docs: add threat model and security policies (#167)
Add THREAT_MODEL.md and SECURITY.md sections: secrets management, release verification, end-of-support, dependency/code-scanning remediation policy, and a VEX statement. Closes OSPS Baseline L3 documentation gaps (SA-03.02, BR-07.02, DO-03.01/03.02/05.01, VM-04.02/05.01/05.02/06.01). Additive only.
2026-06-03 22:40:56 +02:00
kingchenc 00ce899cc3 docs: add public ROADMAP (#165)
Add a public ROADMAP.md describing project direction and pointing to the issue tracker as the authoritative view. Closes the OpenSSF Silver documentation_roadmap gap.
2026-06-03 22:19:24 +02:00
kingchenc b6ead740e8 docs: add governance, support, DCO and security assurance case (#164)
Add GOVERNANCE.md, MAINTAINERS.md, SUPPORT.md, DCO; add a DCO sign-off requirement to CONTRIBUTING.md and a security assurance case to SECURITY.md. Closes OpenSSF Silver / OSPS Baseline documentation gaps. Additive only.
2026-06-03 22:16:03 +02:00
kingchenc 755f4aa0f6 docs: add OpenSSF Best Practices badge to README (#163)
Adds the OpenSSF Best Practices passing badge next to the OpenSSF Scorecard badge in the README header.

The project earned a passing badge: https://www.bestpractices.dev/projects/13094
2026-06-03 21:44:34 +02:00
kingchenc 4d602df8a3 release: bump 0.5.0 -> 0.5.1 (#162)
Version bump **0.5.0 → 0.5.1** for the Seasonality & Session family release (12 indicators, PR #161).

Bumped: `Cargo.toml` (workspace version + `wickra-core` dep), `Cargo.lock` (via `cargo build`), `bindings/python/pyproject.toml`, `bindings/node/package.json` (+ 6 `optionalDependencies`), the 6 `bindings/node/npm/<platform>/package.json`, both `package-lock.json` files, and `CHANGELOG.md` (`[Unreleased]` → `[0.5.1]` + compare URLs).

No code changes — version strings only.
2026-06-03 20:55:13 +02:00
kingchenc 3ab2d6ec2d feat(seasonality): add the Seasonality & Session family (12 indicators) (#161)
## Summary

Adds the **Seasonality & Session** family — the first family that reads the wall-clock fields of `Candle::timestamp`. A new private `calendar` module decomposes an epoch-millisecond instant (shifted by a per-indicator `utc_offset_minutes`) into civil fields via Howard Hinnant's branch-light `civil_from_days` algorithm. Session / day / month rollovers are detected automatically, so callers never have to invoke `reset()` at a boundary.

Indicator counter **339 → 351**; family count **20 → 21**.

## Indicators

| Shape | Indicators |
|-------|-----------|
| Scalar (`f64`) | `SessionVwap`, `AverageDailyRange`, `OvernightGap`, `TurnOfMonth`, `SeasonalZScore` |
| Struct | `SessionHighLow`, `SessionRange` (Asia/EU/US), `OvernightIntradayReturn` |
| Profile (`Vec<f64>`) | `TimeOfDayReturnProfile`, `DayOfWeekProfile`, `IntradayVolatilityProfile`, `VolumeByTimeProfile` |

## Bindings

The input is the **full** candle (`open, high, low, close, volume, timestamp`), not the `high/low/close` slice the value-indicator helper assumes, so the Python / Node / WASM bindings are custom full-candle implementations:

- **Python** — `update((o,h,l,c,v,ts))`; `batch(open, high, low, close, volume, timestamp)` → `PyArray1` (scalar) / `PyArray2` (struct & profile), warmup rows `NaN`.
- **Node** — `update(open, high, low, close, volume, timestamp)`; `batch(...)` → flat `Vec<f64>`; struct outputs as `#[napi(object)]` values.
- **WASM** — `update` only (multi-input precedent); profiles as `Float64Array`, structs as camelCase objects, `timestamp` as `BigInt`.

## Verification

- `wickra-core`: full per-branch unit tests, **100%** coverage target; 2852 lib tests + 334 doctests green.
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean.
- Node: 428 tests (dedicated `seasonality.test.js` streaming-vs-batch).
- Python: full suite + dedicated `test_seasonality.py` streaming-vs-batch.
- Counter check: mod-count == counted lib block == 351.
2026-06-03 20:31:32 +02:00
kingchenc 5e96d41916 chore: add REUSE-style LICENSES directory for license auto-detection (#160)
Adds a `LICENSES/` directory with SPDX-named copies of the existing license texts (`MIT.txt`, `Apache-2.0.txt`) per the [REUSE Specification](https://reuse.software/spec/).

## Why
Automated license scanners (the OpenSSF Best Practices BadgeApp, GitHub's license API, REUSE tooling) look for a top-level `LICENSE`/`COPYING` file or a `LICENSES/` directory with SPDX-named files. Our files are named `LICENSE-MIT` / `LICENSE-APACHE` (Rust convention), which these scanners do not recognize — so the BadgeApp's `license_location` check keeps auto-flipping to "Unmet".

## What
- New `LICENSES/MIT.txt` — byte-identical copy of `LICENSE-MIT`
- New `LICENSES/Apache-2.0.txt` — byte-identical copy of `LICENSE-APACHE`
- Existing `LICENSE-MIT` and `LICENSE-APACHE` are **unchanged**

The project remains dual-licensed under **MIT OR Apache-2.0**. This change is additive only.
2026-06-03 20:00:40 +02:00
202 changed files with 56300 additions and 441 deletions
+2
View File
@@ -5,5 +5,7 @@
maturin
numpy
pandas
TA-Lib
tulipy
talipp
finta
+80
View File
@@ -1,5 +1,13 @@
# This file was autogenerated by uv via the following command:
# ./scripts/update-lockfiles.sh
build==1.5.0 \
--hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f \
--hash=sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647
# via ta-lib
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via build
finta==1.3 \
--hash=sha256:b94b94df311c18bf5402eb2fe8fd2db5e1bdaff08baf58a7367d05c7abdd10d3 \
--hash=sha256:f2fa0673748f4be8f57e57cf6d5c00a4d44bc6071ea69dbb9a1d329d045cbba2
@@ -97,6 +105,12 @@ numpy==2.4.6 \
# -r .github/requirements/bench.in
# finta
# pandas
# ta-lib
# tulipy
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via build
pandas==3.0.3 \
--hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
--hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
@@ -149,6 +163,10 @@ pandas==3.0.3 \
# via
# -r .github/requirements/bench.in
# finta
pyproject-hooks==1.2.0 \
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
# via build
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
@@ -157,10 +175,72 @@ six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
ta-lib==0.6.8 \
--hash=sha256:02388054c059945e5f02625f5075bac20a1803573cb43e7d096091027511961f \
--hash=sha256:094677b279a59c3f01c3aca8a889fda3523fd641a3805f69a2d642121b72e55e \
--hash=sha256:0a08a29690a922ba92a6cf42902a8a93c6fbda4cfed62c3c5b0471560ef60135 \
--hash=sha256:0ccd478ff5735831bf2a61d653466bfda8afadc26ad58ca6b1edb9e7521cc674 \
--hash=sha256:0e371d14b49e70caa973a234c8823341dd446f5c5d7acc826868bb42b272bdc0 \
--hash=sha256:11a373c9308eae3bac2d56d37017f9ab63968cc074a8b95be879aae3d13133aa \
--hash=sha256:128ec92e6a0e9ff7a38edef80e3b74f15bb2ed1c531d5d3252c8dca22677651b \
--hash=sha256:1fb4028437201e19014e4e374272b739867c8a3eb655da46675ef4c2ff14b616 \
--hash=sha256:282e49c766b5952dd8796f77d7ed3ae412cdd88e31f845b1fbbb86ac6cb7bebf \
--hash=sha256:2b369cabb48485fbf444beb3f5a878075367b99c2c86db2f796afeabebc749e0 \
--hash=sha256:2bf714333788bf5175f2512b86d2ed129e89ae6f6c2923e8a297a1e3395e13b5 \
--hash=sha256:30de46b55873b51be945a09edf486afcc190dc47eff9fb5d2b12c9f7e3d743da \
--hash=sha256:34e3b12407ddf99f6627435aa8a165f094339bb7dc33de92e1d7472e9f237304 \
--hash=sha256:36b2a516fce57309840f5ef3fa2fd0c4449293fc72536a0400d2e1e26b414da8 \
--hash=sha256:3a9195299df9d7d2a6e9d16bebd6b706b0ea99e4b871864c4b034c2577e21a77 \
--hash=sha256:3c32fc0f546ceecc47dd45f33d72ab4a1e341b80d9081c2d77b100add5d49104 \
--hash=sha256:3d7333e907bff3e3997e54f89733ffa8d619842a3e1cd962bca34bdc11944c28 \
--hash=sha256:4795e93d130c9b7fb661f0cead49752ae6a980437df74b99d5918026c212443e \
--hash=sha256:490e19a45cd3cdd6dfe6b46019f7ffe1103500750b41b51996a870e7c1c5f066 \
--hash=sha256:4aa0fe08383f3e5fc7d2f8cf9b42ac778f4d53fd75bcd2799a858225954eab89 \
--hash=sha256:559326d8f3d904cd4aa61f6a392d5626f35eec6a9f6cc83bcddb0abf88c40516 \
--hash=sha256:5929c83bd8cb7572d1c17ffdbf0eac235bf3c4d53cde1950cf89d944eaf97525 \
--hash=sha256:5bfd21b6acb32e20d4e279c34405a34e63da345be4b2b6eabd683e1a88857406 \
--hash=sha256:613cf06313331f49dd7b85a5a24fbddb1156c9723b6921a231906241726e5aee \
--hash=sha256:66a8e1c1e899d15a2f7510e43527fba22d895e7f6058d027db3e3837d88a69de \
--hash=sha256:691a62926ba09f2653ec0908554b3635497efb7751c5d46b916cd1ebbb1d3c25 \
--hash=sha256:6c1fd18e45c39d5a4be4b0d6a20c141e43fe46daeb1b2e2f304ebae7015ab6e6 \
--hash=sha256:6c6a1e8f98de92e817491b50aa4d01d69a1b41a4ed3173747e8f16f0d4cf81cc \
--hash=sha256:6cf029b886cfb28a2701503b7c602b811f2daa45276bd6459b0c71e051deb497 \
--hash=sha256:71506116eac0d3e3598d6325b4b818c3a0f6acb3222b24d30ad726e8c4bf7ea8 \
--hash=sha256:7993164e8e9f78ec31d38c47850ca6ba5451788b5b49a8a2dbb3322b36b5693b \
--hash=sha256:7a5cc6bf60791d8274edfdfe2dd7cec3f00f656dcc92e2b0a9af06c8b18ce6a6 \
--hash=sha256:87c1cc1057d903b78a8257a7c5f497db6fd5284f5080392bd57b66031d7389a3 \
--hash=sha256:98376c75bd6c103c74396953084a5e0798ffe476aecbfcc51ec6d100a685ac38 \
--hash=sha256:a395524b0fafa10446d11e11acb4742e919523de58aac03b791f26d7a783bcf0 \
--hash=sha256:a5100a4be91b7d4b7c8fe16a3600bd0951e10205eb1066b6873afd3996b51ee4 \
--hash=sha256:a63a52221f8c73f82f4e00493351d987f594931198589287aee96f8da673cfd5 \
--hash=sha256:a89734a7bcb2ea3b6fd600a74d6fbcdb8d3fa3f7917dbd978e039710b5509c9c \
--hash=sha256:b165f5e6de1ccc964e863bd2035807a4d3bad3e0481f9db2dc52034d6ad4f9de \
--hash=sha256:b3845e4c2fa32963fb7f384ebbaa2761b0e6b96145239bf80e956d4aff4b071c \
--hash=sha256:b3b017d9103e7a7372a146773be32b184ff7330bd708d40b1f56f06a686756ed \
--hash=sha256:b6c6e4858d8c3f88e19b7aa94b6a7619108f0bee51da9fa67b0785a8b59955f9 \
--hash=sha256:bfad1202fb1f9140e3810cc607058395f59032d9128cc0d716900c78bea5f337 \
--hash=sha256:c01809fb602e2fefc8cbfb3b603bb59d2a2eaee8708410896d48a835ba00e7c5 \
--hash=sha256:cce8de9d48289927ed18aaa420740efd52b2cd9289da32e3799afbb3a02822e8 \
--hash=sha256:ce2bc1ea01200b6d8130ab917296d05d77a1a571ec6c1ee25cfca6d55cd5db4a \
--hash=sha256:d4601e2a8b46ffbf540601a4926fd6cc5aae8a13b36fdd467f1040f01f9edaed \
--hash=sha256:d556d1c256b3700b60b6b061664a667b2e49d599c2772d46a9f2348f2dc4ab5c \
--hash=sha256:ddf7453acd03b966624ebefdb38169b5bbbeea1a1a58c90b095667247f9de327 \
--hash=sha256:e781eeb65b2007af553389c8a7fb7bc53cb856118b0fcffb2c26b0f49561c686 \
--hash=sha256:e920c272cd9e70a6b10eae9203cc96845da142e1dd4482de9343dda3738a9862 \
--hash=sha256:f5b6174bf4bf9152e368561dff410203c6921e4dd2afbcda3283a95957158112 \
--hash=sha256:f69bd42fd2515060af69b120668213121264bb7976b113954b6f9db327727c65 \
--hash=sha256:f823d0f6b04a6797fbe253bcf91666e71a6b63c290683819650c68b2468ebe64 \
--hash=sha256:fa7e9f2e80a9535f9692e113d02b4268b5f88675a730d1b0ef0abeb74c9a4e80
# via -r .github/requirements/bench.in
talipp==2.7.0 \
--hash=sha256:567f59ad74366cb59a14a00d350f35fd9d22e6924d6228bad581e6dcf1de2205 \
--hash=sha256:f749f22b9ad615605e71faf26457bb7f5e3fe16f04d3287f4ca54fd16bc3d4eb
# via -r .github/requirements/bench.in
tulipy==0.4.0 \
--hash=sha256:540704956b5b940a5f6306aa393a37536a6d7c3cbc07efe47512f3496e5203ab \
--hash=sha256:95542e40537afdd345d875baf37485eac993c6a819d00c51432e9de8df21eba8 \
--hash=sha256:fbc31727ef7657c93ad910bfdce65fecc6aaa7a5e961fe00240718e7a3fc79d8
# via -r .github/requirements/bench.in
tzdata==2026.2 \
--hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
--hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
+26
View File
@@ -117,3 +117,29 @@ jobs:
with:
name: cross-library-bench
path: bindings/python/benchmark.txt
rust-cross-bench:
name: Rust cross-library benchmark report
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# Wickra vs the other Rust TA crates (kand, ta-rs, yata) on an identical
# candle series — the like-for-like engine comparison with no binding
# overhead. Streaming + batch, in crates/wickra-bench/benches/cross_lib.rs.
- name: Run Rust cross-library benchmark
run: cargo bench -p wickra-bench --bench cross_lib | tee rust_cross_bench.txt
- name: Upload Rust report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rust-cross-bench
path: rust_cross_bench.txt
+7
View File
@@ -33,6 +33,13 @@ jobs:
with:
results_file: results.sarif
results_format: sarif
# The default GITHUB_TOKEN cannot read classic branch-protection
# rules, so the Branch-Protection check fails with an internal error
# and scores -1. A read-only fine-grained PAT (Administration: read,
# Contents: read, Metadata: read) supplied as SCORECARD_TOKEN lets the
# check read the protection settings. See
# https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Publish to the public OpenSSF endpoint that backs the README badge.
publish_results: true
+2 -2
View File
@@ -180,14 +180,14 @@ jobs:
exit 0
fi
cd docs-count
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md overview.md Indicators-Overview.md .vitepress/config.ts
if git diff --quiet; then
echo "Docs indicator count unchanged."
exit 0
fi
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add index.md overview.md Indicators-Overview.md
git add index.md overview.md Indicators-Overview.md .vitepress/config.ts
git commit -m "chore: sync indicator count to ${n}"
if ! git push 2>/dev/null; then
echo "::warning::push to wickra-lib/wickra-docs failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
+96
View File
@@ -0,0 +1,96 @@
# Benchmarks
Read these as **relative** speedups on identical input — absolute µs depend on
CPU, memory clock and OS scheduler, not a universal contract. **Streaming is the
headline**: it is where Wickra's design pays off and where the gap is measured in
orders of magnitude, not percent. The batch numbers come second and are shown
honestly — the leanest crates edge Wickra out on the simple recurrences, and that
is a deliberate trade for warmup/NaN semantics, not a ceiling.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
- **Reproduce yourself:**
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
- Python vs Python libs: `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
## 1. Streaming — the structural win
Live trading feeds one tick at a time. Wickra updates every indicator in **O(1)**;
batch-only libraries (TA-Lib, tulipy, finta, pandas-ta) have no incremental API
and must recompute the whole history on every tick. Only `talipp` (Python) and
`ta-rs` / `yata` (Rust) carry real per-tick state. This is the gap the library
was built to expose.
**Python — per-tick latency** (seed 5 000 bars, then feed ticks one at a time):
| Indicator | **★&nbsp;Wickra** | talipp | TA-Lib (recompute) |
|------------------|------------------:|------------------|-----------------------|
| SMA(20) | **0.063 µs ★** | 0.59 µs (9×) | 204 µs (3 300×) |
| EMA(20) | **0.060 µs ★** | 0.72 µs (12×) | 212 µs (3 500×) |
| RSI(14) | **0.065 µs ★** | 1.06 µs (16×) | 230 µs (3 600×) |
| MACD(12, 26, 9) | **0.078 µs ★** | 4.22 µs (54×) | 245 µs (3 100×) |
| Bollinger(20, 2) | **0.088 µs ★** | 5.15 µs (58×) | 229 µs (2 600×) |
Against the only other incremental Python peer Wickra is **958× faster**;
against the recompute-on-every-tick libraries it is **2 60014 000× faster**
(`finta` RSI hits 14 000×). tulipy / pandas-ta land in the same recompute band
as TA-Lib.
**Rust — per-tick latency** (whole 50 000-bar series, lower = faster):
| Indicator | **★&nbsp;Wickra** | kand | ta-rs | yata |
|------------------|------------------:|-----:|------:|-----:|
| SMA(20) | 50 | 38 | 47 | 38 |
| EMA(20) | 154 | 69 | 56 | 69 |
| RSI(14) | 164 | 216 | 74 | — |
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
| Bollinger(20, 2) | **128 ★** | 248 | 168 | — |
| ATR(14) | 152 | 166 | 61 | — |
`ta-rs` hands back a bare `f64` from the first tick with no warmup and no
validation; it leads several rows by giving those guarantees up. Against `kand`,
Wickra wins streaming RSI, Bollinger and ATR. `yata` exposes only SMA/EMA as
raw-value methods, so its other rows are omitted rather than faked.
## 2. Batch — competitive, not the headline
Whole series in one call. Here hand-tuned C (`tulipy`, TA-Lib) and the leanest
Rust crate (`kand`) win the simple recurrences — Wickra trades a few µs per pass
for the `None`-warmup, NaN-safety and bit-exact `batch == streaming` guarantees
none of them keep. It still wins several rows outright and beats the rest of the
field everywhere.
**Python** (20 000-bar pass, µs/op, lower = faster):
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta |
|------------------|---------:|-------:|-------:|----------:|
| SMA(20) | 22.7 | **15.4** | 15.9 | 33.7 |
| EMA(20) | 30.8 | **30.3** | 31.1 | 48.8 |
| RSI(14) | 58.9 | 72.5 | **38.5** | 94.8 |
| MACD(12, 26, 9) | 71.7 | 99.1 | **33.5** | 207.6 |
| Bollinger(20, 2) | 84.9 | 65.7 | **32.3** | 336.4 |
| ATR(14) | 52.0 | 79.4 | **31.9** | — |
Wickra beats TA-Lib on RSI, MACD and ATR and the whole Python field on every
row; tulipy's SIMD C stays ahead on the heavier indicators.
**Rust** (50 000-bar pass, µs, lower = faster). Only Wickra and `kand` expose a
batch API; `ta-rs` and `yata` are streaming-only:
| Indicator | **★&nbsp;Wickra** | kand |
|------------------|------------------:|-------:|
| SMA(20) | 53 | **41** |
| EMA(20) | 111 | **71** |
| RSI(14) | **221 ★** | 259 |
| MACD(12, 26, 9) | 533 | **327** |
| Bollinger(20, 2) | **404 ★** | 460 |
| ATR(14) | **122 ★** | 169 |
Run the suite yourself:
```bash
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
pip install -e bindings/python[bench] # Python peers
python -m benchmarks.compare_libraries
```
+220 -1
View File
@@ -7,6 +7,207 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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 15 impulse / AC correction sequence (`TDDWave`).
- **TD Moving Averages** — the DeMark ST1 (fast) and ST2 (slow) median-price trend ribbon whose crossover frames the trend (`TDMovingAverage`).
## [0.6.6] - 2026-06-08
- **Pivot Reversal** — a breakout signal when price closes through the most recently confirmed swing pivot (`PIVOT_REVERSAL`).
- **Volume-Weighted Support/Resistance** — a band whose edges are the volume-weighted average of recent highs and lows (`VOLUME_WEIGHTED_SR`).
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
## [0.6.5] - 2026-06-07
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
## [0.6.4] - 2026-06-07
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`).
- **Shannon Entropy** — Shannon entropy of a rolling value distribution over fixed bins (`SHANNONENT`).
- **Rolling Min-Max Scaler** — Rolling min-max scaler mapping the latest value to 0..1 over a rolling window (`ROLLINGMINMAX`).
- **Jarque-Bera** — Jarque-Bera normality test statistic over a rolling window (`JARQUEBERA`).
## [0.6.3] - 2026-06-07
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
## [0.6.2] - 2026-06-07
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
- **NRTR** — NRTR (Nick Rypock Trailing Reverse) — percentage trailing-reverse stop (`NRTR`).
- **ATR Ratchet** — ATR Ratchet — Kaufman per-bar tightening volatility trailing stop (`ATR_RATCHET`).
- **Elder SafeZone** — Elder SafeZone Stop — average noise-penetration trailing stop with directional flip (`ELDER_SAFE_ZONE`).
- **Kase DevStop** — Kase DevStop volatility trailing stop using standard-deviation of two-bar true range (`KASE_DEV_STOP`).
## [0.6.1] - 2026-06-07
- **Projection Oscillator** — Widner projection oscillator: close position inside the projection bands, scaled 0..100 (`ProjectionOscillator`).
- **Projection Bands** — Widner projection bands: forward-projected high/low regression envelope (`ProjectionBands`).
- **Median Channel** — robust median +/- multiplier*MAD envelope (`MedianChannel`).
- **Bomar Bands** — adaptive percentage bands containing a target coverage fraction of recent closes (`BomarBands`).
- **Quartile Bands** — rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope (`QuartileBands`).
## [0.6.0] - 2026-06-06
- **Volatility Cone** — volatility cone: current realized volatility within its historical min/median/max envelope (`VolatilityCone`).
- **VolatilityRatio** — Schwager's volatility ratio: true range over the EMA of prior true ranges (`VolatilityRatio`).
- **BipowerVariation** — jump-robust realized bipower variation (pi/2 sum of adjacent absolute log-return products) (`BipowerVariation`).
- **VolatilityOfVolatility** — vol-of-vol: sample stddev of a rolling realized-volatility series (`VolatilityOfVolatility`).
- **Garch11** — GARCH(1,1) conditional volatility with a long-run-variance anchor (`Garch11`).
- **EwmaVolatility** — RiskMetrics exponentially-weighted volatility of log returns (lambda decay) (`EwmaVolatility`).
## [0.5.9] - 2026-06-06
### Added
- Internal Rust cross-library benchmark harness (`crates/wickra-bench`, not
published) comparing Wickra against `kand`, `ta-rs` and `yata` on an identical
candle series in both streaming and batch modes; wired into the nightly
`cross-library-bench` workflow.
- `tulipy` runners and expanded per-tick streaming coverage (SMA, EMA, RSI,
MACD, Bollinger) in the Python `compare_libraries` benchmark.
### Changed
- Faster streaming and batch updates for SMA, Bollinger Bands, RSI, EMA and ATR
(flat ring buffers replacing `VecDeque`, hoisted reciprocals in the Wilder
smoothing, leaner hot state) — indicator outputs are unchanged.
- Rewrote the README benchmark section into honest, tiered tables (Rust core vs
the other Rust crates, and Python vs the Python ecosystem) that show where
Wickra wins and where it loses, not only the favourable comparisons.
## [0.5.8] - 2026-06-04
- **TSF Oscillator** — the percentage gap of the close to the one-bar-ahead time-series forecast, a close-relative companion to CFO (`TsfOscillator`).
- **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`).
- **PPO Histogram** — the Percentage Price Oscillator with its signal EMA and the resulting zero-centered histogram (`PpoHistogram`).
## [0.5.7] - 2026-06-04
- **Qstick** — Qstick (Chande), the SMA of the candle body (close open) as a net buying/selling pressure gauge (`QSTICK`).
- **TTM Trend** — TTM Trend (John Carter), +1/1 by whether the close sits above the SMA of recent median prices (`TTM_TREND`).
- **Trend Strength Index** — trend strength index, the signed r² of a linear regression of price against time (`TREND_STRENGTH_INDEX`).
- **Polarized Fractal Efficiency** — polarized fractal efficiency (Hannula), directional trend efficiency over a fractal lookback (`POLARIZED_FRACTAL_EFFICIENCY`).
- **Wave PM** — Wave PM (Kase), a variance-normalised peak-momentum statistic (`WAVE_PM`).
- **Gator Oscillator** — Gator Oscillator (Bill Williams), the Alligator convergence/divergence histogram (`GATOR_OSCILLATOR`).
- **Kase Permission Stochastic** — Kase Permission Stochastic, a double-smoothed stochastic used as a trade-permission filter (`KASE_PERMISSION_STOCHASTIC`).
## [0.5.6] - 2026-06-04
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
## [0.5.5] - 2026-06-04
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`).
- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`).
- **Median MA** — median moving average, the rolling median of prices (`MedianMA`).
- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`).
- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`).
## [0.5.4] - 2026-06-04
- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
- **VPIN** — volume-synchronised probability of informed trading (volume-bucketed order-flow toxicity) (`Vpin`).
- **Order Flow Imbalance** — rolling sum of best-level order-flow events (Cont-Kukanov-Stoikov OFI) (`OrderFlowImbalance`).
- **Expectancy** — expected return per unit of average loss (R-multiple) over a rolling window of returns (`Expectancy`).
- **Win Rate** — fraction of strictly-positive returns over a rolling window (`WinRate`).
- **Regime Label** — volatility-quantile regime classification: 1 calm / 0 normal / +1 stressed, by where the rolling volatility sits in its own recent distribution (`RegimeLabel`).
- **Jump Indicator** — flags return outliers beyond `threshold ×` trailing return volatility (1 down / 0 / +1 up) (`JumpIndicator`).
- **Trend Label** — discrete trend state from the sign of the rolling least-squares slope (1 / 0 / +1) (`TrendLabel`).
- **High-Low Range** — bar high-low range as a fraction of close (scale-free per-bar volatility) (`HighLowRange`).
- **Wick Ratio** — signed upper-vs-lower shadow imbalance as a fraction of the range (`WickRatio`).
- **Body Size Percent** — absolute candle body as a fraction of the bar range (`BodySizePct`).
- **Close vs Open** — signed body as a fraction of the open price, `(close open) / open` (`CloseVsOpen`).
- **Spread AR(1) Coefficient** — first-order autoregression coefficient of the spread `a b` (direct cointegration / mean-reversion strength) (`SpreadAr1Coefficient`).
- **Rolling Quantile** — interpolated q-th quantile over a trailing window (type-7 / NumPy default) (`RollingQuantile`).
- **Rolling Percentile Rank** — percentile rank of the latest value within its trailing window (`RollingPercentileRank`).
- **Rolling IQR** — interquartile range (Q3 Q1) over a trailing window (robust dispersion) (`RollingIqr`).
- **Realized Volatility** — square root of the summed squared log returns (raw, un-annualised quadratic variation) (`RealizedVolatility`).
- **Log Return** — logarithmic return over a fixed lag, `ln(price_t / price_{tperiod})` (`LogReturn`).
## [0.5.3] - 2026-06-04
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
- **Fibonacci Channel** — a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width (`FIB_CHANNEL`).
- **Fibonacci Arcs** — semicircular retracement levels centred on the swing end, normalised by leg bar-width (`FIB_ARCS`).
- **Fibonacci Fan** — three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels (`FIB_FAN`).
- **Fibonacci Confluence** — densest cluster of retracement levels across recent swing legs (price + strength) (`FIB_CONFLUENCE`).
- **Golden Pocket** — the 0.618-0.65 optimal-trade-entry band of the most recent swing leg (`GOLDEN_POCKET`).
- **Auto-Fibonacci** — retracement anchored on the dominant (largest-magnitude) leg among recent swings (`AUTO_FIB`).
- **Fibonacci Projection** — measured-move target zone from the last three pivots (A-B-C), projecting A->B from C (`FIB_PROJECTION`).
- **Fibonacci Extension** — projects the latest swing leg to the canonical extension ratios (127.2/141.4/161.8/200/261.8%) (`FIB_EXTENSION`).
- **Fibonacci Retracement** — seven retracement levels (0/23.6/38.2/50/61.8/78.6/100%) of the most recent confirmed swing leg (`FIB_RETRACEMENT`).
## [0.5.2] - 2026-06-03
### Added
- **Three Drives** — three symmetric drives with extension legs; bullish +1, bearish -1 (`THREE_DRIVES`).
- **Cypher** — five-point harmonic whose D retraces XC by 0.786; bullish +1, bearish -1 (`CYPHER`).
- **Shark** — five-point harmonic with an expansion leg and 0.886-1.13 D; bullish +1, bearish -1 (`SHARK`).
- **Crab** — five-point harmonic with the deepest (1.618 XA) D completion; bullish +1, bearish -1 (`CRAB`).
- **Bat** — five-point harmonic with a shallow B and 0.886 D completion; bullish +1, bearish -1 (`BAT`).
- **Butterfly** — five-point harmonic with an extended (1.27-1.618 XA) D; bullish +1, bearish -1 (`BUTTERFLY`).
- **Gartley** — five-point harmonic with a 0.786 D completion; bullish +1, bearish -1 (`GARTLEY`).
- **AB=CD** — four-point AB=CD harmonic: BC retraces AB, CD mirrors AB; bullish +1, bearish -1 (`ABCD`).
- **Cup and Handle** — rounded base with a shallow handle near the rim; bullish +1, inverse -1 (`CUP_AND_HANDLE`).
- **Rectangle / Range** — flat support and resistance; mean-reversion signal off the just-touched boundary; support +1, resistance -1 (`RECTANGLE_RANGE`).
- **Flag / Pennant** — shallow consolidation against a sharp pole; continuation in the pole direction; bull +1, bear -1 (`FLAG_PENNANT`).
- **Wedge (rising/falling)** — both trendlines slope the same way but converge; rising wedge -1, falling wedge +1 (`WEDGE`).
- **Triangle (asc/desc/sym)** — converging trendlines; ascending +1, descending -1, symmetrical follows the last swing (`TRIANGLE`).
- **Head and Shoulders** — central head flanked by two matching shoulders over a flat neckline; top -1, inverse +1 (`HEAD_AND_SHOULDERS`).
- **Triple Top / Bottom** — three matching peaks / troughs; a stronger reversal than the double; bearish -1, bullish +1 (`TRIPLE_TOP_BOTTOM`).
- **Double Top / Bottom** — twin-peak / twin-trough reversal confirmed on the second matching swing extreme; bearish -1, bullish +1 (`DOUBLE_TOP_BOTTOM`).
## [0.5.1] - 2026-06-03
### Added — Seasonality & Session family (12 indicators)
- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
## [0.5.0] - 2026-06-03
### Added
@@ -1168,7 +1369,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.8...HEAD
[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
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
[0.5.9]: https://github.com/wickra-lib/wickra/compare/v0.5.8...v0.5.9
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
+30
View File
@@ -122,3 +122,33 @@ installed. Dependabot also keeps the `.github/requirements` pins current.
Use the issue templates under
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
## Developer Certificate of Origin (DCO)
All contributions to Wickra are made under the [Developer Certificate of
Origin (DCO) 1.1](DCO). By signing off on your commits you certify that you
wrote the patch, or otherwise have the right to submit it under the project's
`MIT OR Apache-2.0` license.
Sign off every commit by adding a `Signed-off-by` trailer with your real name
and email — Git adds it automatically with the `-s` flag:
```bash
git commit -s -m "your message"
```
This produces a trailer of the form:
```
Signed-off-by: Your Name <you@example.com>
```
The name and email must match the commit author. Commits without a valid
sign-off line cannot be merged. To sign off a commit you already made, amend it
with `git commit -s --amend`, or sign off a range with an interactive rebase.
## Governance
Wickra's decision-making and maintainership are described in
[`GOVERNANCE.md`](GOVERNANCE.md); the current maintainers are listed in
[`MAINTAINERS.md`](MAINTAINERS.md).
Generated
+114 -7
View File
@@ -702,6 +702,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kand"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1f41590bd014ef6c3dd815b45f07deb4c3198e355a4319bb7521b6a3a6aeb5"
dependencies = [
"num_enum",
"thiserror",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -911,6 +921,28 @@ dependencies = [
"libm",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "numpy"
version = "0.28.0"
@@ -1081,6 +1113,15 @@ dependencies = [
"syn",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1498,6 +1539,12 @@ dependencies = [
"syn",
]
[[package]]
name = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -1607,6 +1654,36 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -1867,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.5.0"
version = "0.6.8"
dependencies = [
"approx",
"criterion",
@@ -1876,9 +1953,21 @@ dependencies = [
"wickra-data",
]
[[package]]
name = "wickra-bench"
version = "0.6.8"
dependencies = [
"criterion",
"kand",
"ta",
"wickra",
"wickra-data",
"yata",
]
[[package]]
name = "wickra-core"
version = "0.5.0"
version = "0.6.8"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.0"
version = "0.6.8"
dependencies = [
"approx",
"csv",
@@ -1905,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.0.0"
version = "0.6.8"
dependencies = [
"serde_json",
"tokio",
@@ -1915,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.0"
version = "0.6.8"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.0"
version = "0.6.8"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.0"
version = "0.6.8"
dependencies = [
"console_error_panic_hook",
"js-sys",
@@ -1991,6 +2080,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -2091,6 +2189,15 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yata"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4ef8ddfa3ccd93454262c0e60a43a2bbf403d404174e1815f7581d5028229f"
dependencies = [
"serde",
]
[[package]]
name = "yoke"
version = "0.8.2"
+3 -2
View File
@@ -8,11 +8,12 @@ members = [
"bindings/wasm",
"bindings/node",
"examples/rust",
"crates/wickra-bench",
]
exclude = ["fuzz"]
[workspace.package]
version = "0.5.0"
version = "0.6.8"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -24,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.5.0" }
wickra-core = { path = "crates/wickra-core", version = "0.6.8" }
thiserror = "2"
rayon = "1.10"
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+71
View File
@@ -0,0 +1,71 @@
# Governance
Wickra is an open-source project maintained under a **single-maintainer
("BDFL") model**. This document describes how decisions are made and how the
project is run, so contributors know what to expect.
## Roles
- **Maintainer.** The maintainer (see [`MAINTAINERS.md`](MAINTAINERS.md)) is
responsible for the project's direction, reviews and merges changes, cuts
releases, and has final say on all technical and project decisions.
- **Contributors.** Anyone who proposes changes via pull requests, files
issues, improves documentation, or otherwise participates. Contributors do
not need any special status to take part.
## Decision-making
- Day-to-day technical decisions (APIs, indicator implementations, refactors)
are made by the maintainer, informed by discussion on issues and pull
requests.
- Proposals are raised as GitHub issues or pull requests. Significant or
breaking changes should be opened as an issue first to agree on the approach
before implementation.
- The maintainer aims to act transparently: rationale for non-trivial decisions
is recorded in the relevant issue, pull request, or commit message.
## Contribution flow
All changes — including the maintainer's own — go through pull requests so that
CI (tests, linting, static analysis) runs against them, and so the change
history is reviewable. Contribution requirements are documented in
[`CONTRIBUTING.md`](CONTRIBUTING.md), including the Developer Certificate of
Origin sign-off that every commit must carry.
## Becoming a maintainer
The project currently has one maintainer. Maintainership may be extended to
contributors who have demonstrated sustained, high-quality involvement, at the
current maintainer's discretion. If the project grows to multiple maintainers,
this document will be updated to describe shared decision-making.
## Continuity and succession
The project is designed to survive the loss of any single individual, so that
issues can be triaged, proposed changes accepted, and releases published within
one week of confirmed loss of the maintainer:
- **Credentials.** All credentials required to operate the project — the
`wickra-lib` GitHub organization, the publishing tokens for crates.io, PyPI
and npm, and the `wickra.org` domain registrar — are stored in a password
manager. A trusted contact (a family member) holds **emergency access** to
that password manager and can obtain these credentials if the maintainer can
no longer continue.
- **Continuity actions.** With that access, the trusted contact (or a delegate
they appoint) can create and close issues, accept pull requests, and publish
releases through the existing CI/CD workflows.
- **Account recovery.** The maintainer's GitHub account has recovery configured,
and ownership of the `wickra-lib` organization can be transferred to a new
maintainer.
- **Legal rights.** Legal rights to the project name and DNS are covered by the
maintainer's estate arrangements.
## Code of conduct
All participants are expected to follow the
[Code of Conduct](CODE_OF_CONDUCT.md).
## Changes to this document
This governance model may evolve as the project grows. Changes are made via
pull request and take effect once merged.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kingchenc and the Wickra contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kingchenc and the Wickra contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# Maintainers
This file lists the current maintainers of Wickra. See
[`GOVERNANCE.md`](GOVERNANCE.md) for what the role entails and how the project
is run.
| Maintainer | GitHub | Areas |
| --- | --- | --- |
| kingchenc | [@kingchenc](https://github.com/kingchenc) | All (core, bindings, CI/release, docs) |
## Contacting the maintainers
- General questions and support: see [`SUPPORT.md`](SUPPORT.md).
- Bug reports and feature requests: open an issue using the
[issue templates](.github/ISSUE_TEMPLATE).
- Security reports: follow [`SECURITY.md`](SECURITY.md) — do **not** open a
public issue.
+100 -93
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=339" 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=479" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -11,6 +11,7 @@
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](#license)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13094/badge)](https://www.bestpractices.dev/projects/13094)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
@@ -47,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 339 indicators; start at the
every one of the 479 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),
@@ -57,111 +58,107 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
[FAQ](https://docs.wickra.org/FAQ).
## Why Wickra
Most TA libraries are fast, *or* multi-language, *or* broad. Wickra refuses to
pick. It's the streaming-first engine built for the workload the others treat as
an afterthought — **live, tick-by-tick data** — without giving up the breadth of
a full batch library, and without making you reimplement your indicators four
times to get there.
- **The biggest streaming-native catalogue, period.** 479 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 479 indicators**.
- **Orders of magnitude faster where it counts.** In streaming Wickra is **958×**
faster than the only other incremental peer and **thousands of times** faster
than recompute-on-every-tick libraries. On batch it wins several rows outright
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
the losses are shown, not hidden.
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra`
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
macOS · Linux · Windows.
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
live Binance kline feed ship in the box.
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
and closed-source work.
Every other library forces one of those compromises. Wickra doesn't:
| Library | Install | Streaming | Languages | Indicators | Active |
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
| **★&nbsp;Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **479** | **yes** |
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
| ta-rs | clean | yes | Rust only | ~30 | stale |
| yata | clean | partial | Rust only | ~35 | yes |
| TA-Lib | yes (C deps)| no | many bindings | ~150 | barely |
| pandas-ta | clean | no | Python | ~130 | slow |
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
the same time. That's the combination no one else ships.
## Why Wickra exists
The Python TA ecosystem has plenty of libraries — TA-Lib, pandas-ta, finta,
talipp, tulipy — and every one of them shares the same blind spot:
Wickra started as a personal itch. The existing TA libraries never quite fit the
projects I was building, so I decided to build one from the ground up — partly to
learn, partly because I genuinely enjoy taking something that already exists and
trying to do it differently (and, ideally, better). It's open source because the
useful version of that itch is the one other people can build on too.
| Library | Install pain | Streaming | Multi-language | Active |
|------------------------|-----------------|-----------|----------------|--------|
| **★&nbsp;Wickra** | **clean** | **yes** | **Python + Node + WASM + Rust** | **yes** |
| TA-Lib (Python) | yes (C deps) | no | no | barely |
| pandas-ta | clean | no | no | slow |
| finta | clean | no | no | stale |
| ta-lib-python | yes (C deps) | no | no | barely |
| talipp | clean | yes | no | yes |
| Tulip Indicators | yes (C deps) | no | partial | stale |
| ooples (C#) | clean | no | C# only | yes |
## Benchmarks
Wickra is the only library that combines all of: clean install, streaming,
multi-language reach, and active maintenance.
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
workload it is built for — it is **958× faster** than the only other incremental
peer and **thousands of times** faster than recompute-on-every-tick libraries.
**Batch** is competitive: it wins several rows outright and trades a few µs
elsewhere for `None`-warmup, NaN-safety and bit-exact `batch == streaming`.
## Benchmark: how much faster is "streaming-first"?
The numbers below were measured on a single developer workstation and are not
guaranteed to reproduce identically on different hardware — absolute µs values
depend on CPU, memory clock and OS scheduler. Read them as **relative
speedups** between libraries on identical input, not as a universal
performance contract.
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
Rust 1.92 (release profile, `lto = "fat"`, `codegen-units = 1`),
Python 3.12, Node 20.
- **Reproduce yourself:** `pip install -e bindings/python[bench]` then
`python -m benchmarks.compare_libraries`. The script auto-detects every
installed peer library and runs them on the same generated inputs as
Wickra. The CI job `cross-library-bench` runs the same script on every
push and uploads the raw report as a build artefact.
Lower µs/op = faster. Wickra wins every batch category outright, and the
streaming gap widens linearly with how much history a batch-only library has
to recompute on every tick.
### Batch — single full pass over a 20 000-bar series
Reading the table: each cell shows that library's runtime, plus how many times
slower it is than Wickra in parentheses. **★** marks the winner per row.
| Indicator | **★&nbsp;Wickra** | finta | talipp |
|---------------------|---------------------|-----------------------------|-------------------------------|
| SMA(20) | **95.6 µs ★** | 343.5 µs (3.6× slower) | 7 640.6 µs (79.9× slower) |
| EMA(20) | **64.6 µs ★** | 223.1 µs (3.5× slower) | 12 160.9 µs (188.2× slower) |
| RSI(14) | **126.2 µs ★** | 1 107.1 µs (8.8× slower) | 15 792.2 µs (125.1× slower) |
| MACD(12, 26, 9) | **119.0 µs ★** | 531.8 µs (4.5× slower) | 49 788.1 µs (418.2× slower) |
| Bollinger(20, 2.0) | **105.3 µs ★** | 812.0 µs (7.7× slower) | 130 938.3 µs (1 243.7× slower)|
| ATR(14) | **123.5 µs ★** | 5 144.8 µs (41.7× slower) | 28 816.0 µs (233.4× slower) |
### Streaming — per-tick latency after seeding with 5 000 historical bars
A batch-only library has to re-run its full indicator over the entire history on
every new tick; Wickra updates state in O(1).
| Indicator | **★&nbsp;Wickra (per tick)** | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.119 µs ★** | 1.644 µs (13.8× slower) |
> TA-Lib and pandas-ta are not included here because both fail to install
> cleanly on Windows without C build tooling — which is precisely the install
> pain Wickra was built to remove. The benchmark script auto-detects every
> peer library it can find and runs them on the same inputs as Wickra; install
> them in your environment to see those rows light up too.
Run the suite yourself:
```bash
pip install -e bindings/python[bench]
python -m benchmarks.compare_libraries
```
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
**[BENCHMARKS.md](BENCHMARKS.md)**.
## Indicators
339 streaming-first indicators across twenty families. Every one passes the
479 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).
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands |
| 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 |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level, TD Camouflage, TD Clop, TD Clopwin, TD Propulsion, TD Trap, TD D-Wave, TD Moving Averages |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi, 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 |
| 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 |
| 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 |
| 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 |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
@@ -240,9 +237,10 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 339 indicators
│ ├── wickra-core/ core engine + all 479 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
── wickra-data/ CSV reader, tick aggregator, live exchange feeds
── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
├── bindings/
│ ├── python/ PyO3 + maturin (publishes on PyPI)
│ ├── node/ napi-rs (publishes on npm)
@@ -256,9 +254,10 @@ wickra/
└── .github/workflows/ CI and release pipelines
```
Rust benchmarks live in `crates/wickra/benches/`; runnable Rust examples live
in the workspace member crate at `examples/rust/`. There is no top-level
`benches/` directory.
Wickra's own regression benchmarks live in `crates/wickra/benches/`; the
cross-library comparison against kand, ta-rs and yata lives in the internal
`crates/wickra-bench/` crate. Runnable Rust examples live in the workspace member
crate at `examples/rust/`. There is no top-level `benches/` directory.
## Building everything from source
@@ -266,7 +265,8 @@ in the workspace member crate at `examples/rust/`. There is no top-level
# Rust core + tests
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo bench -p wickra
cargo bench -p wickra # Wickra's own regression benchmarks
cargo bench -p wickra-bench # cross-library comparison (kand, ta-rs, yata)
# Python binding (requires Rust toolchain + maturin)
cd bindings/python
@@ -366,3 +366,10 @@ The library is provided **as is**, without warranty of any kind; see
<p align="center">
If Wickra saved you time, the cheapest way to say thanks is to ⭐ the repo.
</p>
<p align="center">
<a href="https://star-history.com/#wickra-lib/wickra&Date">
<img alt="Wickra star history" width="640"
src="https://api.star-history.com/svg?repos=wickra-lib/wickra&type=Date&theme=dark">
</a>
</p>
+36
View File
@@ -0,0 +1,36 @@
# Roadmap
This roadmap describes the project's direction at a high level. It is
intentionally non-binding: priorities shift with feedback and available time,
and the authoritative, up-to-date view of planned work is the
[issue tracker](https://github.com/wickra-lib/wickra/issues). Shipped changes
are recorded in [`CHANGELOG.md`](CHANGELOG.md).
## Status
Wickra is **pre-1.0**. The public API is largely stable but may still change in
minor releases; breaking changes are called out in the changelog.
## Themes
- **Indicator coverage.** Continue broadening the indicator catalogue across
families (trend, momentum, volatility, volume, statistics, market profile,
and more), each with the same streaming/batch parity and test guarantees.
- **API stabilization toward 1.0.** Settle the public `Indicator` and
`BarBuilder` traits and the binding surfaces, then commit to semantic
versioning stability for a 1.0 release.
- **Performance.** Keep per-tick updates O(1) and maintain the benchmark suite;
investigate further allocation and cache improvements.
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings in
lockstep with the Rust core, including type stubs and platform coverage.
- **Documentation.** Maintain a deep-dive page per indicator on
<https://docs.wickra.org>, plus quickstarts and cookbook material.
- **Project health.** Maintain test coverage, static and dynamic analysis,
signed releases, and supply-chain monitoring.
## How to influence the roadmap
Open or comment on an issue, or start with the
[feature-request template](.github/ISSUE_TEMPLATE/feature_request.md).
Well-scoped proposals and pull requests are the most effective way to move an
item forward.
+96
View File
@@ -41,3 +41,99 @@ PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
Out of scope: vulnerabilities in third-party dependencies (report those
upstream; we track them via Dependabot and `cargo-deny`).
## Security assurance case
This is a short, evidence-backed argument for why Wickra can be used safely.
**Security requirements.** Wickra is a computational library: it ingests
numeric market data and produces indicator values. It stores no user
credentials, authenticates no external users, and implements no cryptography of
its own. The requirements are therefore: (1) memory safety and freedom from
undefined behaviour, (2) robust handling of untrusted/degenerate numeric input
without panics or unbounded resource use, (3) integrity of the published
artifacts, and (4) a healthy dependency supply chain.
**How the requirements are met.**
- *Memory safety* — the core and all bindings are written in Rust. The crates
forbid or minimise `unsafe`, so the compiler guarantees memory and thread
safety for the indicator logic.
- *Input robustness* — every indicator validates its parameters and rejects
non-finite inputs at construction; behaviour on edge cases (flat markets,
warmup, reset) is pinned by unit tests, and the public update paths are
exercised by coverage-guided fuzzing (`cargo-fuzz` / libFuzzer) in CI.
- *Static and dynamic analysis* — every push and pull request runs Clippy
(`clippy::pedantic`, warnings-as-errors), CodeQL, fuzzing, and the full test
suite, with 100% line coverage on the core crate tracked by Codecov.
- *Artifact integrity* — releases are built in CI, commits and tags are signed,
the `main` branch requires signed commits, and release artifacts carry build
provenance attestations.
- *Supply chain* — dependencies are pinned and monitored with Dependabot and
audited with `cargo-deny` (license + advisory checks) on every change.
**Residual risk.** The optional `live-binance` feature opens a TLS WebSocket to
an exchange using the platform TLS library; transport security therefore
depends on that library, not on Wickra. Wickra is not a trading system and is
provided "as is" — see the disclaimers in `README.md` and the licenses.
## Secrets management
The project stores **no** secrets or credentials in the version control system.
Secrets required by automation (publishing tokens, the about-sync PAT) are kept
exclusively as **GitHub Actions encrypted secrets** and referenced via the
`secrets.*` context; they are never written to the repository, logs, or build
artifacts. GitHub **secret scanning with push protection** is enabled to block
accidental commits of credentials. Secrets follow least privilege (the narrowest
scope that works) and are rotated when a holder changes or on suspected
exposure.
## Verifying releases
Released artifacts can be verified for integrity and authenticity:
- **Build provenance.** Release assets carry GitHub build provenance
attestations. Verify a downloaded asset with the GitHub CLI:
`gh attestation verify <file> --repo wickra-lib/wickra`.
- **Signed tags.** Each release corresponds to a signed git tag (`vX.Y.Z`);
the tag signature identifies the maintainer who authorised the release.
- **Registry integrity.** Packages are distributed over HTTPS from crates.io,
PyPI and npm, which serve package checksums that package managers verify on
install.
The release is published only by the maintainer through the tag-triggered
release workflow, so a verified tag signature establishes the expected
publisher identity.
## Support timeline and end of support
Wickra is **pre-1.0**: only the **latest released `0.y.z`** version receives
security fixes. When a newer release is published, the previous version
**immediately reaches end of support** and will not receive further fixes;
users should upgrade to the latest release. The supported-versions table above
is authoritative. After the `1.0.0` release this policy will be revised to
support a defined window of releases.
## Remediation policy (dependencies and code scanning)
- **Severity threshold.** Vulnerabilities of **medium severity or higher** in
the project's own code or its dependencies are remediated promptly and before
the next release; lower-severity findings are addressed on a best-effort
basis.
- **Automated enforcement (SCA).** Every change is evaluated by `cargo-deny`
(RUSTSEC advisories + license policy) and Dependabot; a known-vulnerable
dependency fails CI and **blocks the change** until resolved or explicitly
waived with justification.
- **Automated enforcement (SAST).** Every change is evaluated by CodeQL and
Clippy (`-D warnings`); findings **block the change** in CI until fixed.
- **Pre-release gate.** A release is not cut while an unresolved medium-or-higher
SCA/SAST finding is outstanding.
## Vulnerability exploitability (VEX)
Advisories reported by `cargo-deny`/Dependabot for third-party dependencies that
do **not** affect Wickra (e.g. the vulnerable code path is not reachable, or the
affected feature is not enabled) are triaged and recorded — with the
not-affected justification — in the `cargo-deny` configuration (`deny.toml`) and
the relevant pull request, rather than forcing an unnecessary dependency bump.
This serves as the project's exploitability (VEX) record.
+37
View File
@@ -0,0 +1,37 @@
# Support
Thanks for using Wickra! Here is where to get help, depending on what you need.
## Documentation first
Most questions are answered in the documentation:
- **Docs site:** <https://docs.wickra.org> — quickstarts for Rust, Python,
Node.js and WebAssembly, a per-indicator reference, warmup periods, the data
layer, and an FAQ.
- **README:** <https://github.com/wickra-lib/wickra#readme> — installation and a
quick overview.
- **API docs (Rust):** <https://docs.rs/wickra>.
## Questions and help
- Ask a question with the
[question issue template](.github/ISSUE_TEMPLATE/question.md).
- Browse [existing issues](https://github.com/wickra-lib/wickra/issues) — your
question may already be answered.
## Bugs and feature requests
- **Bugs:** use the bug-report issue template.
- **Feature requests / new indicators:** use the feature-request template.
## Security issues
Please do **not** report security vulnerabilities through public issues. Follow
the process in [`SECURITY.md`](SECURITY.md) (private GitHub advisory or email).
## Support expectations
Wickra is maintained by a single maintainer on a best-effort basis. Issues are
triaged and acknowledged as time allows; there is no commercial support or SLA.
Clear, reproducible reports get help fastest.
+54
View File
@@ -0,0 +1,54 @@
# Threat model
This document describes Wickra's attack surface and the threats considered,
together with their mitigations. It complements the security assurance case in
[`SECURITY.md`](SECURITY.md). Wickra is a computational technical-analysis
library (a Rust core with Python, Node.js and WebAssembly bindings), not a
network service or trading system; the attack surface is correspondingly small.
## Assets
- **Integrity of computed indicator values** — consumers may use them in
automated decisions, so silently wrong output is the primary concern.
- **Availability of the calling process** — a library must not crash or hang
its host on malformed input.
- **Integrity of published artifacts** — the crates, wheels and npm packages
users install.
- **The build and release pipeline** and its secrets (publishing tokens).
## Actors / trust boundaries
- **Library consumer** (trusted) — calls the API with numeric data. Data may
originate from untrusted sources (e.g. a market feed), so *input values* are
treated as untrusted even though the caller is trusted.
- **Optional live feed** — with the `live-binance` feature, data crosses a
network boundary from an exchange over TLS.
- **Contributors** (semi-trusted) — propose changes via pull requests.
- **Supply chain** — upstream dependencies and the CI/CD platform.
## Threats and mitigations
| Threat | Mitigation |
| --- | --- |
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
| Adversary-in-the-middle on the optional live feed | Connection uses TLS via the platform library; transport security is delegated to that reviewed implementation. |
| Compromised dependency (supply chain) | Dependencies pinned (`Cargo.lock`, hash-locked CI requirements), monitored by Dependabot, audited by `cargo-deny` (advisories + licenses) on every change. |
| Malicious or accidental change to `main` | Branch protection requires signed commits and blocks force-push and deletion; all changes flow through pull requests with required CI; static analysis (CodeQL, Clippy) and fuzzing run on every change. |
| Compromised CI / leaked secrets | Workflows use least-privilege `permissions:`; secrets live only as encrypted GitHub Actions secrets; secret scanning with push protection is enabled; workflows are linted by `zizmor`. |
| Tampered release artifact | Releases are built in CI, tags are signed, and assets carry build provenance attestations (verifiable with `gh attestation verify`). |
## Out of scope
- Wickra implements no authentication, authorization or cryptography of its own,
stores no user data, and exposes no network listener; those threat classes do
not apply.
- Vulnerabilities in third-party dependencies that do not affect Wickra are
tracked as exploitability (VEX) records (see [`SECURITY.md`](SECURITY.md)).
## Maintenance
This threat model is reviewed when the architecture changes materially (for
example, a new input family, a new network feature, or a new release channel).
+175
View File
@@ -28,6 +28,52 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
UNIVERSALOSC: () => new wickra.UNIVERSALOSC(20),
ADAPTIVERSI: () => new wickra.ADAPTIVERSI(14),
CTI: () => new wickra.CTI(20),
TRENDFLEX: () => new wickra.TRENDFLEX(20),
REFLEX: () => new wickra.REFLEX(20),
HIGHPASS: () => new wickra.HIGHPASS(48),
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
JARQUEBERA: () => new wickra.JARQUEBERA(20),
BipowerVariation: () => new wickra.BipowerVariation(20),
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
EwmaVolatility: () => new wickra.EwmaVolatility(0.94),
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
TsfOscillator: () => new wickra.TsfOscillator(3),
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
RMI: () => new wickra.RMI(14, 5),
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
RSX: () => new wickra.RSX(14),
FisherRSI: () => new wickra.FisherRSI(14),
DisparityIndex: () => new wickra.DisparityIndex(14),
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
GD: () => new wickra.GD(5, 0.7),
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
MedianMA: () => new wickra.MedianMA(14),
EHMA: () => new wickra.EHMA(9),
GMA: () => new wickra.GMA(14),
SWMA: () => new wickra.SWMA(14),
Expectancy: () => new wickra.Expectancy(20),
WinRate: () => new wickra.WinRate(20),
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
JumpIndicator: () => new wickra.JumpIndicator(20, 3.0),
TrendLabel: () => new wickra.TrendLabel(10),
RollingQuantile: () => new wickra.RollingQuantile(20, 0.5),
RollingPercentileRank: () => new wickra.RollingPercentileRank(14),
RollingIqr: () => new wickra.RollingIqr(14),
RealizedVolatility: () => new wickra.RealizedVolatility(20),
LogReturn: () => new wickra.LogReturn(1),
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
@@ -297,6 +343,49 @@ const candleScalar = {
TasukiGap: { make: () => new wickra.TasukiGap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
UniqueThreeRiver: { make: () => new wickra.UniqueThreeRiver(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ConcealingBabySwallow: { make: () => new wickra.ConcealingBabySwallow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
DoubleTopBottom: { make: () => new wickra.DoubleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TripleTopBottom: { make: () => new wickra.TripleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HeadAndShoulders: { make: () => new wickra.HeadAndShoulders(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Triangle: { make: () => new wickra.Triangle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Wedge: { make: () => new wickra.Wedge(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
FlagPennant: { make: () => new wickra.FlagPennant(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RectangleRange: { make: () => new wickra.RectangleRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CupAndHandle: { make: () => new wickra.CupAndHandle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Abcd: { make: () => new wickra.Abcd(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Gartley: { make: () => new wickra.Gartley(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Butterfly: { make: () => new wickra.Butterfly(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Bat: { make: () => new wickra.Bat(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Crab: { make: () => new wickra.Crab(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
CloseVsOpen: { make: () => new wickra.CloseVsOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TDCamouflage: { make: () => new wickra.TDCamouflage(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDClop: { make: () => new wickra.TDClop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDClopwin: { make: () => new wickra.TDClopwin(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDPropulsion: { make: () => new wickra.TDPropulsion(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDTrap: { make: () => new wickra.TDTrap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TDDWave: { make: () => new wickra.TDDWave(2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
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) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -369,6 +458,39 @@ const multi = {
// Family 13: Ichimoku & alternative charts
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
HeikinAshi: { make: () => new wickra.HeikinAshi(), 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) },
FibRetracement: { make: () => new wickra.FibRetracement(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibExtension: { make: () => new wickra.FibExtension(), fields: ['level1272', 'level1414', 'level1618', 'level2000', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibProjection: { make: () => new wickra.FibProjection(), fields: ['level618', 'level1000', 'level1618', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
AutoFib: { make: () => new wickra.AutoFib(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
GoldenPocket: { make: () => new wickra.GoldenPocket(), fields: ['low', 'mid', 'high'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibConfluence: { make: () => new wickra.FibConfluence(), fields: ['price', 'strength'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibFan: { make: () => new wickra.FibFan(), fields: ['fan382', 'fan500', 'fan618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QuartileBands: { make: () => new wickra.QuartileBands(4), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BomarBands: { make: () => new wickra.BomarBands(4, 0.85), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MedianChannel: { make: () => new wickra.MedianChannel(5, 2.0), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
ProjectionBands: { make: () => new wickra.ProjectionBands(3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
KaseDevStop: { make: () => new wickra.KaseDevStop(3, 1.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ElderSafeZone: { make: () => new wickra.ElderSafeZone(14, 2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
TDMovingAverage: { make: () => new wickra.TDMovingAverage(5, 13), fields: ['st1', 'st2'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
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)) {
@@ -537,6 +659,8 @@ const pairFactories = {
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
KendallTau: () => new wickra.KendallTau(20),
};
for (const [name, make] of Object.entries(pairFactories)) {
@@ -1100,6 +1224,57 @@ test('trade-flow rejects bad input', () => {
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
});
test('order-flow imbalance reference + streaming matches batch', () => {
// Rising bid (px up, size 6) with an unchanged ask -> +6 flow.
const ofi = new wickra.OrderFlowImbalance(1);
assert.equal(ofi.update([100], [5], [101], [4]), null); // seeds the reference
assert.ok(Math.abs(ofi.update([100.5], [6], [101], [4]) - 6.0) < 1e-12);
const snaps = Array.from({ length: 30 }, (_, i) => ({
bidPx: [100 + Math.sin(i * 0.3)],
bidSz: [5 + Math.abs(Math.cos(i * 0.5))],
askPx: [101 + Math.sin(i * 0.3)],
askSz: [4 + Math.abs(Math.sin(i * 0.4))],
}));
const batch = new wickra.OrderFlowImbalance(10).batch(snaps);
const streamer = new wickra.OrderFlowImbalance(10);
assert.equal(batch.length, snaps.length);
for (let i = 0; i < snaps.length; i++) {
const s = streamer.update(snaps[i].bidPx, snaps[i].bidSz, snaps[i].askPx, snaps[i].askSz);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
});
test('vpin / amihud / roll reference + streaming matches batch', () => {
// VPIN: two pure-buy buckets of size 10 -> imbalance == size -> 1.
const v = new wickra.Vpin(10, 2);
let last;
for (let i = 0; i < 4; i++) last = v.update(100, 5, true);
assert.equal(last, 1.0);
// Amihud(1): |ln(101/100)| / (101 * 10).
const a = new wickra.AmihudIlliquidity(1);
assert.equal(a.update(100, 10, true), null);
assert.ok(Math.abs(a.update(101, 10, true) - Math.abs(Math.log(101 / 100)) / (101 * 10)) < 1e-15);
// Roll(6): a clean bid-ask bounce of ±1 implies a spread of 2.
const r = new wickra.RollMeasure(6);
let roll = null;
for (let i = 0; i < 20; i++) roll = r.update(i % 2 === 0 ? 100 : 101, 1, true);
assert.ok(Math.abs(roll - 2.0) < 1e-12);
// Streaming-vs-batch for the three trade-input indicators.
const n = 40;
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)]) {
const batch = make().batch(price, size, isBuy);
const streamer = make();
assert.equal(batch.length, n);
for (let i = 0; i < n; i++) {
const s = streamer.update(price[i], size[i], isBuy[i]);
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
}
}
});
test('price-impact indicators reference values', () => {
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
@@ -0,0 +1,96 @@
// Streaming-vs-batch equivalence and reference values for the Seasonality &
// Session family. These indicators consume the full candle (open, high, low,
// close, volume, timestamp), so they have a dedicated suite.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
const HOUR = 3_600_000;
const N = 240;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
const high = close.map((c, i) => Math.max(open[i], c) + 1);
const low = close.map((c, i) => Math.min(open[i], c) - 1);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function streamScalar(ind, i) {
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
return v === null || v === undefined ? NaN : v;
}
function checkScalar(name, make) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
}
});
}
function checkMatrix(name, make, k, pick) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
for (let j = 0; j < k; j += 1) {
const s = out === null || out === undefined ? NaN : pick(out, j);
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
}
}
});
}
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
checkMatrix(
'OvernightIntradayReturn',
() => new wickra.OvernightIntradayReturn(0),
2,
(o, j) => (j === 0 ? o.overnight : o.intraday),
);
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
test('SessionVwap reference value', () => {
const vwap = new wickra.SessionVwap(0);
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
});
test('OvernightGap reference value', () => {
const gap = new wickra.OvernightGap(0);
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
});
test('SessionHighLow reference object', () => {
const shl = new wickra.SessionHighLow(0);
shl.update(100, 105, 99, 101, 1, 0);
const out = shl.update(101, 108, 100, 107, 1, HOUR);
assert.ok(eq(out.high, 108));
assert.ok(eq(out.low, 99));
});
test('AverageDailyRange rejects zero period', () => {
assert.throws(() => new wickra.AverageDailyRange(0, 0));
});
+1490
View File
File diff suppressed because it is too large Load Diff
+141 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.5.0",
"version": "0.6.8",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.0",
"version": "0.6.8",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.0",
"version": "0.6.8",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.0",
"version": "0.6.8",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.0",
"version": "0.6.8",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.0",
"version": "0.6.8",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.0",
"version": "0.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.0",
"version": "0.6.8",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
"wickra-darwin-arm64": "0.6.8",
"wickra-darwin-x64": "0.6.8",
"wickra-linux-arm64-gnu": "0.6.8",
"wickra-linux-x64-gnu": "0.6.8",
"wickra-win32-arm64-msvc": "0.6.8",
"wickra-win32-x64-msvc": "0.6.8"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.tgz",
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.8.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.tgz",
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.8.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.tgz",
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.8.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.tgz",
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.8.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.tgz",
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.8.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.tgz",
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.8.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.5.0",
"version": "0.6.8",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-darwin-arm64": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0"
"wickra-linux-x64-gnu": "0.6.8",
"wickra-linux-arm64-gnu": "0.6.8",
"wickra-darwin-x64": "0.6.8",
"wickra-darwin-arm64": "0.6.8",
"wickra-win32-x64-msvc": "0.6.8",
"wickra-win32-arm64-msvc": "0.6.8"
},
"scripts": {
"build": "napi build --platform --release",
File diff suppressed because it is too large Load Diff
+368 -16
View File
@@ -49,6 +49,7 @@ TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta")
TULIPY = _try_import("tulipy")
PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable
@@ -71,13 +72,23 @@ class Sample:
return (self.seconds / self.iterations) * 1_000_000
def time_call(fn: Callable[[], None], iterations: int) -> float:
"""Time ``fn`` over ``iterations`` calls, returning total wall seconds."""
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
"""Time ``fn`` over ``iterations`` calls per round, across ``rounds`` rounds.
Returns the *median* round's wall seconds for one round of ``iterations``
calls. Taking the median across several rounds damps the OS scheduling and
GC jitter that a single timing pass would otherwise bake into the result,
so the per-iteration figure is stable run-to-run. Callers keep dividing the
return value by ``iterations``.
"""
fn() # one warmup call to populate caches
start = time.perf_counter()
for _ in range(iterations):
fn()
return time.perf_counter() - start
rounds_s: List[float] = []
for _ in range(rounds):
start = time.perf_counter()
for _ in range(iterations):
fn()
rounds_s.append(time.perf_counter() - start)
return statistics.median(rounds_s)
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
@@ -275,6 +286,34 @@ def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices))
# tulipy wraps the C "Tulip Indicators" library; it takes contiguous float64
# arrays and indicator options as positional arguments.
def tulipy_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.sma(prices, 20))
def tulipy_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.ema(prices, 20))
def tulipy_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.rsi(prices, 14))
def tulipy_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.macd(prices, 12, 26, 9))
def tulipy_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.bbands(prices, 20, 2.0))
def tulipy_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.atr(high, low, close, 14))
# --------------------------------------------------------------------------- #
# Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- #
@@ -329,6 +368,260 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl
return run
# Scalar streaming peers: Wickra and talipp both update incrementally in O(1),
# so this is the like-for-like per-tick comparison (batch-only libs are covered
# by the batch tables and the recompute contrast on RSI above).
def wickra_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
sma = WICKRA.SMA(20)
sma.batch(seed)
for p in live:
sma.update(float(p))
return run
def talipp_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import SMA # type: ignore
def run() -> None:
sma = SMA(period=20, input_values=list(seed))
for p in live:
sma.add(float(p))
return run
def wickra_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
ema = WICKRA.EMA(20)
ema.batch(seed)
for p in live:
ema.update(float(p))
return run
def talipp_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
def run() -> None:
ema = EMA(period=20, input_values=list(seed))
for p in live:
ema.add(float(p))
return run
def wickra_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
macd = WICKRA.MACD()
macd.batch(seed)
for p in live:
macd.update(float(p))
return run
def talipp_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
def run() -> None:
macd = MACD(
fast_period=12, slow_period=26, signal_period=9, input_values=list(seed)
)
for p in live:
macd.add(float(p))
return run
def wickra_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
bb = WICKRA.BollingerBands(20, 2.0)
bb.batch(seed)
for p in live:
bb.update(float(p))
return run
def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
def run() -> None:
bb = BB(period=20, std_dev_mult=2.0, input_values=list(seed))
for p in live:
bb.add(float(p))
return run
# Recompute streaming peers: batch-only libraries have no incremental API, so
# the only honest way to drive them tick-by-tick is to re-run the full batch
# over the grown history on every new price. These runners expose exactly that
# cost — the gap Wickra's O(1) update closes.
def _talib_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(np.asarray(history))
return run
def _pandas_ta_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(PD.Series(history))
return run
def _tulipy_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(np.asarray(history, dtype=np.float64))
return run
def _finta_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
arr = np.asarray(history)
fn(PD.DataFrame({"open": arr, "high": arr, "low": arr, "close": arr, "volume": np.ones_like(arr)}))
return run
def talib_sma_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.SMA(a, timeperiod=20))
def pandas_ta_sma_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.sma(s, length=20))
def tulipy_sma_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.sma(a, 20))
def finta_sma_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.SMA(df, period=20))
def talib_ema_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.EMA(a, timeperiod=20))
def pandas_ta_ema_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.ema(s, length=20))
def tulipy_ema_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.ema(a, 20))
def finta_ema_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.EMA(df, period=20))
def tulipy_rsi_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.rsi(a, 14))
def finta_rsi_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.RSI(df, period=14))
def talib_macd_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.MACD(a))
def pandas_ta_macd_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.macd(s))
def tulipy_macd_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.macd(a, 12, 26, 9))
def finta_macd_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.MACD(df))
def talib_bollinger_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.BBANDS(a, timeperiod=20, nbdevup=2, nbdevdn=2))
def pandas_ta_bollinger_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.bbands(s, length=20, std=2.0))
def tulipy_bollinger_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.bbands(a, 20, 2.0))
def finta_bollinger_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0))
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
@@ -339,6 +632,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch),
("tulipy", tulipy_sma_batch),
("finta", finta_sma_batch),
("talipp", talipp_sma_batch),
]),
@@ -346,6 +640,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch),
("tulipy", tulipy_ema_batch),
("finta", finta_ema_batch),
("talipp", talipp_ema_batch),
]),
@@ -353,6 +648,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch),
("tulipy", tulipy_rsi_batch),
("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch),
]),
@@ -360,6 +656,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch),
("tulipy", tulipy_macd_batch),
("finta", finta_macd_batch),
("talipp", talipp_macd_batch),
]),
@@ -367,6 +664,7 @@ BATCH_INDICATORS = [
("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch),
("tulipy", tulipy_bollinger_batch),
("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch),
]),
@@ -376,29 +674,64 @@ OHLC_INDICATORS = [
("ATR(14)", [
("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch),
("tulipy", tulipy_atr_batch),
("finta", finta_atr_batch),
("talipp", talipp_atr_batch),
]),
]
STREAMING_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_streaming),
("talipp", talipp_sma_streaming),
("TA-Lib", talib_sma_streaming),
("pandas-ta", pandas_ta_sma_streaming),
("tulipy", tulipy_sma_streaming),
("finta", finta_sma_streaming),
]),
("EMA(20)", [
("Wickra", wickra_ema_streaming),
("talipp", talipp_ema_streaming),
("TA-Lib", talib_ema_streaming),
("pandas-ta", pandas_ta_ema_streaming),
("tulipy", tulipy_ema_streaming),
("finta", finta_ema_streaming),
]),
("RSI(14)", [
("Wickra", wickra_rsi_streaming),
("talipp", talipp_rsi_streaming),
("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming),
("talipp", talipp_rsi_streaming),
("tulipy", tulipy_rsi_streaming),
("finta", finta_rsi_streaming),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_streaming),
("talipp", talipp_macd_streaming),
("TA-Lib", talib_macd_streaming),
("pandas-ta", pandas_ta_macd_streaming),
("tulipy", tulipy_macd_streaming),
("finta", finta_macd_streaming),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_streaming),
("talipp", talipp_bollinger_streaming),
("TA-Lib", talib_bollinger_streaming),
("pandas-ta", pandas_ta_bollinger_streaming),
("tulipy", tulipy_bollinger_streaming),
("finta", finta_bollinger_streaming),
]),
]
def run_batch(prices: np.ndarray, iterations: int) -> List[Sample]:
def run_batch(prices: np.ndarray, iterations: int, rounds: int) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in BATCH_INDICATORS:
for lib_name, factory in libs:
runner = factory(prices)
if runner is None:
continue
secs = time_call(runner, iterations)
secs = time_call(runner, iterations, rounds)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
@@ -408,6 +741,7 @@ def run_ohlc(
low: np.ndarray,
close: np.ndarray,
iterations: int,
rounds: int,
) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in OHLC_INDICATORS:
@@ -415,12 +749,12 @@ def run_ohlc(
runner = factory(high, low, close)
if runner is None:
continue
secs = time_call(runner, iterations)
secs = time_call(runner, iterations, rounds)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) -> List[Sample]:
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int, rounds: int) -> List[Sample]:
out: List[Sample] = []
seed = prices[:streaming_window]
live = prices[streaming_window:]
@@ -431,7 +765,7 @@ def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) ->
runner = factory(seed, live)
if runner is None:
continue
secs = time_call(runner, iterations)
secs = time_call(runner, iterations, rounds)
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
sample.iterations = iterations * len(live) # per-tick normalization
out.append(sample)
@@ -479,6 +813,12 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
parser.add_argument(
"--rounds",
type=int,
default=5,
help="batch timing rounds; the median round is reported to damp jitter",
)
parser.add_argument(
"--streaming-window",
type=int,
@@ -491,6 +831,14 @@ def parse_args() -> argparse.Namespace:
default=3,
help="repetitions of the streaming workload (each iteration replays all live ticks)",
)
parser.add_argument(
"--streaming-rounds",
type=int,
default=2,
help="streaming timing rounds; the median round is reported",
)
parser.add_argument("--skip-batch", action="store_true", help="skip the batch tables")
parser.add_argument("--skip-streaming", action="store_true", help="skip the streaming tables")
return parser.parse_args()
@@ -501,6 +849,7 @@ def main() -> None:
available = []
if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta")
if TULIPY is not None: available.append("tulipy")
if FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
@@ -509,11 +858,14 @@ def main() -> None:
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
high, low, close, _ = gen_ohlc(args.size)
batch_rows = run_batch(prices, args.iterations)
ohlc_rows = run_ohlc(high, low, close, args.iterations)
streaming_rows = run_streaming(prices, args.streaming_window, args.streaming_iterations)
rows: List[Sample] = []
if not args.skip_batch:
rows += run_batch(prices, args.iterations, args.rounds)
rows += run_ohlc(high, low, close, args.iterations, args.rounds)
if not args.skip_streaming:
rows += run_streaming(prices, args.streaming_window, args.streaming_iterations, args.streaming_rounds)
print(render_table(batch_rows + ohlc_rows + streaming_rows))
print(render_table(rows))
if __name__ == "__main__":
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.5.0"
version = "0.6.8"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
@@ -39,6 +39,7 @@ bench = [
"pytest-benchmark>=4",
"TA-Lib; platform_system != 'Windows'",
"pandas-ta>=0.3.14b",
"tulipy>=0.4; platform_system != 'Windows'",
"talipp>=2",
"finta>=1.3",
"pandas>=2",
+290
View File
@@ -25,6 +25,69 @@ from __future__ import annotations
from ._wickra import (
__version__,
AUTOCORRPGRAM,
EVENBETTERSINE,
BANDPASS,
ADAPTIVECCI,
UNIVERSALOSC,
ADAPTIVERSI,
CTI,
TRENDFLEX,
REFLEX,
HIGHPASS,
SAMPLEENT,
SHANNONENT,
ROLLINGMINMAX,
JARQUEBERA,
TimeBasedStop,
ProjectionOscillator,
VolatilityCone,
VolatilityRatio,
BipowerVariation,
VolatilityOfVolatility,
Garch11,
EwmaVolatility,
PpoHistogram,
MacdHistogram,
TsfOscillator,
Qstick,
GatorOscillator,
KasePermissionStochastic,
WAVE_PM,
POLARIZED_FRACTAL_EFFICIENCY,
TREND_STRENGTH_INDEX,
TTM_TREND,
QQE,
IMI,
ElderRay,
DerivativeOscillator,
RMI,
StochasticCCI,
DynamicMomentumIndex,
RSX,
FisherRSI,
DisparityIndex,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
JumpIndicator,
TrendLabel,
HighLowRange,
WickRatio,
BodySizePct,
CloseVsOpen,
RollingQuantile,
RollingPercentileRank,
RollingIqr,
RealizedVolatility,
LogReturn,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
@@ -120,6 +183,11 @@ from ._wickra import (
HistoricalVolatility,
BollingerBandwidth,
PercentB,
# Trailing Stops
ModifiedMaStop,
Nrtr,
AtrRatchet,
ElderSafeZone,
SuperTrend,
ChandelierExit,
ChandeKrollStop,
@@ -131,6 +199,7 @@ from ._wickra import (
PercentageTrailingStop,
StepTrailingStop,
RenkoTrailingStop,
KaseDevStop,
TrueRange,
ChaikinVolatility,
RVIVolatility,
@@ -139,6 +208,13 @@ from ._wickra import (
RogersSatchellVolatility,
YangZhangVolatility,
# Volume
VolumeWeightedMacd,
BetterVolume,
IntradayIntensity,
TradeVolumeIndex,
TwiggsMoneyFlow,
Wad,
VolumeRsi,
OBV,
VWAP,
RollingVWAP,
@@ -159,6 +235,7 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
KendallTau,
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
@@ -189,6 +266,7 @@ from ._wickra import (
PearsonCorrelation,
Beta,
PairwiseBeta,
SpreadAr1Coefficient,
PairSpreadZScore,
LeadLagCrossCorrelation,
Cointegration,
@@ -215,6 +293,10 @@ from ._wickra import (
MAMA,
FAMA,
# Bands & Channels
ProjectionBands,
MedianChannel,
BomarBands,
QuartileBands,
MaEnvelope,
AccelerationBands,
StarcBands,
@@ -227,6 +309,11 @@ from ._wickra import (
FractalChaosBands,
VwapStdDevBands,
# Pivots & S/R
PivotReversal,
VolumeWeightedSr,
AndrewsPitchfork,
MurreyMathLines,
CentralPivotRange,
ClassicPivots,
FibonacciPivots,
Camarilla,
@@ -235,6 +322,13 @@ from ._wickra import (
WilliamsFractals,
ZigZag,
# DeMark
TDMovingAverage,
TDDWave,
TDTrap,
TDPropulsion,
TDClopwin,
TDClop,
TDCamouflage,
TDSetup,
TDSequential,
TDDeMarker,
@@ -250,6 +344,11 @@ from ._wickra import (
# Ichimoku & alternative charts
Ichimoku,
HeikinAshi,
SmoothedHeikinAshi,
HeikinAshiOscillator,
ThreeLineBreak,
Equivolume,
CandleVolume,
# Market Profile
ValueArea,
VolumeProfile,
@@ -321,7 +420,37 @@ from ._wickra import (
TasukiGap,
UniqueThreeRiver,
ConcealingBabySwallow,
# Chart patterns
CupAndHandle,
RectangleRange,
FlagPennant,
Wedge,
Triangle,
HeadAndShoulders,
TripleTopBottom,
DoubleTopBottom,
# Harmonic patterns
ThreeDrives,
Cypher,
Shark,
Crab,
Bat,
Butterfly,
Gartley,
Abcd,
# Fibonacci
FibTimeZones,
FibChannel,
FibArcs,
FibFan,
FibConfluence,
GoldenPocket,
AutoFib,
FibProjection,
FibExtension,
FibRetracement,
# Microstructure: order book
OrderFlowImbalance,
OrderBookImbalanceTop1,
OrderBookImbalanceTopN,
OrderBookImbalanceFull,
@@ -329,6 +458,9 @@ from ._wickra import (
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
RollMeasure,
AmihudIlliquidity,
Vpin,
SignedVolume,
CumulativeVolumeDelta,
TradeImbalance,
@@ -385,9 +517,85 @@ from ._wickra import (
TreynorRatio,
InformationRatio,
Alpha,
# Seasonality & Session
SessionVwap,
SessionHighLow,
SessionRange,
AverageDailyRange,
OvernightGap,
OvernightIntradayReturn,
TurnOfMonth,
SeasonalZScore,
TimeOfDayReturnProfile,
DayOfWeekProfile,
IntradayVolatilityProfile,
VolumeByTimeProfile,
)
__all__ = [
"AUTOCORRPGRAM",
"EVENBETTERSINE",
"BANDPASS",
"ADAPTIVECCI",
"UNIVERSALOSC",
"ADAPTIVERSI",
"CTI",
"TRENDFLEX",
"REFLEX",
"HIGHPASS",
"SAMPLEENT",
"SHANNONENT",
"ROLLINGMINMAX",
"JARQUEBERA",
"TimeBasedStop",
"ProjectionOscillator",
"VolatilityCone",
"VolatilityRatio",
"BipowerVariation",
"VolatilityOfVolatility",
"Garch11",
"EwmaVolatility",
"PpoHistogram",
"MacdHistogram",
"TsfOscillator",
"Qstick",
"GatorOscillator",
"KasePermissionStochastic",
"WAVE_PM",
"POLARIZED_FRACTAL_EFFICIENCY",
"TREND_STRENGTH_INDEX",
"TTM_TREND",
"QQE",
"IMI",
"ElderRay",
"DerivativeOscillator",
"RMI",
"StochasticCCI",
"DynamicMomentumIndex",
"RSX",
"FisherRSI",
"DisparityIndex",
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
"JumpIndicator",
"TrendLabel",
"HighLowRange",
"WickRatio",
"BodySizePct",
"CloseVsOpen",
"RollingQuantile",
"RollingPercentileRank",
"RollingIqr",
"RealizedVolatility",
"LogReturn",
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
@@ -484,6 +692,11 @@ __all__ = [
"HistoricalVolatility",
"BollingerBandwidth",
"PercentB",
# Trailing Stops
"ModifiedMaStop",
"Nrtr",
"AtrRatchet",
"ElderSafeZone",
"SuperTrend",
"ChandelierExit",
"ChandeKrollStop",
@@ -495,6 +708,7 @@ __all__ = [
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"KaseDevStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
@@ -503,6 +717,13 @@ __all__ = [
"RogersSatchellVolatility",
"YangZhangVolatility",
# Volume
"VolumeWeightedMacd",
"BetterVolume",
"IntradayIntensity",
"TradeVolumeIndex",
"TwiggsMoneyFlow",
"Wad",
"VolumeRsi",
"OBV",
"VWAP",
"RollingVWAP",
@@ -523,6 +744,7 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"KendallTau",
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
@@ -553,6 +775,7 @@ __all__ = [
"PearsonCorrelation",
"Beta",
"PairwiseBeta",
"SpreadAr1Coefficient",
"PairSpreadZScore",
"LeadLagCrossCorrelation",
"Cointegration",
@@ -579,6 +802,10 @@ __all__ = [
"MAMA",
"FAMA",
# Bands & Channels
"ProjectionBands",
"MedianChannel",
"BomarBands",
"QuartileBands",
"MaEnvelope",
"AccelerationBands",
"StarcBands",
@@ -591,6 +818,11 @@ __all__ = [
"FractalChaosBands",
"VwapStdDevBands",
# Pivots & S/R
"PivotReversal",
"VolumeWeightedSr",
"AndrewsPitchfork",
"MurreyMathLines",
"CentralPivotRange",
"ClassicPivots",
"FibonacciPivots",
"Camarilla",
@@ -599,6 +831,13 @@ __all__ = [
"WilliamsFractals",
"ZigZag",
# DeMark
"TDMovingAverage",
"TDDWave",
"TDTrap",
"TDPropulsion",
"TDClopwin",
"TDClop",
"TDCamouflage",
"TDSetup",
"TDSequential",
"TDDeMarker",
@@ -614,6 +853,11 @@ __all__ = [
# Ichimoku & alternative charts
"Ichimoku",
"HeikinAshi",
"SmoothedHeikinAshi",
"HeikinAshiOscillator",
"ThreeLineBreak",
"Equivolume",
"CandleVolume",
# Market Profile
"ValueArea",
"VolumeProfile",
@@ -685,7 +929,37 @@ __all__ = [
"TasukiGap",
"UniqueThreeRiver",
"ConcealingBabySwallow",
# Chart patterns
"CupAndHandle",
"RectangleRange",
"FlagPennant",
"Wedge",
"Triangle",
"HeadAndShoulders",
"TripleTopBottom",
"DoubleTopBottom",
# Harmonic patterns
"ThreeDrives",
"Cypher",
"Shark",
"Crab",
"Bat",
"Butterfly",
"Gartley",
"Abcd",
# Fibonacci
"FibTimeZones",
"FibChannel",
"FibArcs",
"FibFan",
"FibConfluence",
"GoldenPocket",
"AutoFib",
"FibProjection",
"FibExtension",
"FibRetracement",
# Microstructure: order book
"OrderFlowImbalance",
"OrderBookImbalanceTop1",
"OrderBookImbalanceTopN",
"OrderBookImbalanceFull",
@@ -693,6 +967,9 @@ __all__ = [
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"RollMeasure",
"AmihudIlliquidity",
"Vpin",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
@@ -749,4 +1026,17 @@ __all__ = [
"TreynorRatio",
"InformationRatio",
"Alpha",
# Seasonality & Session
"SessionVwap",
"SessionHighLow",
"SessionRange",
"AverageDailyRange",
"OvernightGap",
"OvernightIntradayReturn",
"TurnOfMonth",
"SeasonalZScore",
"TimeOfDayReturnProfile",
"DayOfWeekProfile",
"IntradayVolatilityProfile",
"VolumeByTimeProfile",
]
+7347 -117
View File
File diff suppressed because it is too large Load Diff
+929 -1
View File
@@ -45,6 +45,52 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.AUTOCORRPGRAM, (10, 48)),
(ta.EVENBETTERSINE, (40, 10)),
(ta.BANDPASS, (20, 0.3)),
(ta.UNIVERSALOSC, (20,)),
(ta.ADAPTIVERSI, (14,)),
(ta.CTI, (20,)),
(ta.TRENDFLEX, (20,)),
(ta.REFLEX, (20,)),
(ta.HIGHPASS, (48,)),
(ta.SAMPLEENT, (20, 2, 0.2)),
(ta.SHANNONENT, (20, 8)),
(ta.ROLLINGMINMAX, (20,)),
(ta.JARQUEBERA, (20,)),
(ta.BipowerVariation, (20,)),
(ta.VolatilityOfVolatility, (20, 20)),
(ta.Garch11, (0.000002, 0.1, 0.88)),
(ta.EwmaVolatility, (0.94,)),
(ta.PpoHistogram, (3, 6, 3)),
(ta.MacdHistogram, (3, 6, 3)),
(ta.TsfOscillator, (3,)),
(ta.WAVE_PM, (32, 3)),
(ta.POLARIZED_FRACTAL_EFFICIENCY, (10, 5)),
(ta.TREND_STRENGTH_INDEX, (20,)),
(ta.DerivativeOscillator, (14, 5, 3, 9)),
(ta.RMI, (14, 5)),
(ta.DynamicMomentumIndex, (14,)),
(ta.RSX, (14,)),
(ta.FisherRSI, (14,)),
(ta.DisparityIndex, (14,)),
(ta.HoltWinters, (0.2, 0.1)),
(ta.GD, (5, 0.7)),
(ta.AdaptiveLaguerre, (13,)),
(ta.MedianMA, (14,)),
(ta.EHMA, (9,)),
(ta.GMA, (14,)),
(ta.SWMA, (14,)),
(ta.Expectancy, (20,)),
(ta.WinRate, (20,)),
(ta.RegimeLabel, (5, 20)),
(ta.JumpIndicator, (20, 3.0)),
(ta.TrendLabel, (10,)),
(ta.RollingQuantile, (20, 0.5)),
(ta.RollingPercentileRank, (14,)),
(ta.RollingIqr, (14,)),
(ta.RealizedVolatility, (20,)),
(ta.LogReturn, (1,)),
(ta.TSF, (14,)),
(ta.LINEARREG_INTERCEPT, (14,)),
(ta.ROCR100, (10,)),
@@ -140,6 +186,10 @@ SCALAR = [
# Family 05 band/channel indicators with scalar input and multi-output.
# `cols` is the expected number of band columns from `batch`.
SCALAR_MULTI = {
"MedianChannel": (lambda: ta.MedianChannel(5, 2.0), 3),
"BomarBands": (lambda: ta.BomarBands(4, 0.85), 3),
"QuartileBands": (lambda: ta.QuartileBands(4), 3),
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
@@ -167,6 +217,8 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.KendallTau, (20,)),
(ta.SpreadAr1Coefficient, (40,)),
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
(ta.BetaNeutralSpread, (20,)),
@@ -330,6 +382,142 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"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),
),
"IntradayIntensity": (
lambda: ta.IntradayIntensity(),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"TradeVolumeIndex": (
lambda: ta.TradeVolumeIndex(0.25),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TwiggsMoneyFlow": (
lambda: ta.TwiggsMoneyFlow(21),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"Wad": (
lambda: ta.Wad(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"VolumeRsi": (
lambda: ta.VolumeRsi(14),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
# Per-bar OHLC transforms (open matters). The streaming harness feeds
# open == close, so batch passes the close column in for open to match.
"HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"WickRatio": (lambda: ta.WickRatio(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"BodySizePct": (lambda: ta.BodySizePct(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"CloseVsOpen": (lambda: ta.CloseVsOpen(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"ThreeDrives": (
lambda: ta.ThreeDrives(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Cypher": (
lambda: ta.Cypher(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Shark": (
lambda: ta.Shark(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Crab": (
lambda: ta.Crab(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Bat": (
lambda: ta.Bat(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Butterfly": (
lambda: ta.Butterfly(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Gartley": (
lambda: ta.Gartley(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Abcd": (
lambda: ta.Abcd(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"CupAndHandle": (
lambda: ta.CupAndHandle(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"RectangleRange": (
lambda: ta.RectangleRange(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"FlagPennant": (
lambda: ta.FlagPennant(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Wedge": (
lambda: ta.Wedge(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"Triangle": (
lambda: ta.Triangle(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"HeadAndShoulders": (
lambda: ta.HeadAndShoulders(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"TripleTopBottom": (
lambda: ta.TripleTopBottom(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"DoubleTopBottom": (
lambda: ta.DoubleTopBottom(),
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
),
"MIDPRICE": (lambda: ta.MIDPRICE(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"AVGPRICE": (lambda: ta.AVGPRICE(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"DX": (lambda: ta.DX(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
@@ -796,6 +984,151 @@ 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),
3,
),
"ModifiedMaStop": (
lambda: ta.ModifiedMaStop(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"Nrtr": (
lambda: ta.Nrtr(2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"AtrRatchet": (
lambda: ta.AtrRatchet(14, 4.0, 0.1),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderSafeZone": (
lambda: ta.ElderSafeZone(14, 2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"KaseDevStop": (
lambda: ta.KaseDevStop(3, 1.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ProjectionBands": (
lambda: ta.ProjectionBands(3),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"VolatilityCone": (
lambda: ta.VolatilityCone(20, 60),
lambda ind, h, l, c, v: ind.batch(h, l, c),
5,
),
"KasePermissionStochastic": (
lambda: ta.KasePermissionStochastic(9, 3),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"GatorOscillator": (
lambda: ta.GatorOscillator(13, 8, 5),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderRay": (
lambda: ta.ElderRay(13),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"FibFan": (
lambda: ta.FibFan(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibArcs": (
lambda: ta.FibArcs(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibChannel": (
lambda: ta.FibChannel(),
lambda ind, h, l, c, v: ind.batch(h, l),
4,
),
"FibTimeZones": (
lambda: ta.FibTimeZones(),
lambda ind, h, l, c, v: ind.batch(h, l),
2,
),
"FibRetracement": (
lambda: ta.FibRetracement(),
lambda ind, h, l, c, v: ind.batch(h, l),
7,
),
"FibExtension": (
lambda: ta.FibExtension(),
lambda ind, h, l, c, v: ind.batch(h, l),
5,
),
"FibProjection": (
lambda: ta.FibProjection(),
lambda ind, h, l, c, v: ind.batch(h, l),
4,
),
"AutoFib": (
lambda: ta.AutoFib(),
lambda ind, h, l, c, v: ind.batch(h, l),
7,
),
"GoldenPocket": (
lambda: ta.GoldenPocket(),
lambda ind, h, l, c, v: ind.batch(h, l),
3,
),
"FibConfluence": (
lambda: ta.FibConfluence(),
lambda ind, h, l, c, v: ind.batch(h, l),
2,
),
"Vortex": (
lambda: ta.Vortex(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -1367,7 +1700,7 @@ def test_kvo_constant_series_is_zero():
assert v == pytest.approx(0.0, abs=1e-12)
def test_williams_ad_reference():
def test_wad_reference():
# bar 0 seeds prev_close = 10.
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
@@ -2354,6 +2687,597 @@ def test_granger_causality_reference():
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_double_top_bottom_reference():
t = ta.DoubleTopBottom()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 3)) == pytest.approx(-1.0)
def test_triple_top_bottom_reference():
t = ta.TripleTopBottom()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((99.99, 119.0, 99.99, 99.99, 1.0, 4)) == pytest.approx(0.0)
assert t.update((107.1, 117.81, 107.1, 107.1, 1.0, 5)) == pytest.approx(-1.0)
def test_head_and_shoulders_reference():
t = ta.HeadAndShoulders()
assert t.update((99.9, 100.0, 99.9, 99.9, 1.0, 0)) == pytest.approx(0.0)
assert t.update((90.0, 99.0, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((90.9, 120.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
assert t.update((92.0, 118.8, 92.0, 92.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((92.92, 101.0, 92.92, 92.92, 1.0, 4)) == pytest.approx(0.0)
assert t.update((90.9, 99.99, 90.9, 90.9, 1.0, 5)) == pytest.approx(-1.0)
def test_triangle_reference():
t = ta.Triangle()
assert t.update((129.87, 130.0, 129.87, 129.87, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 128.7, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((110.0, 118.8, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((111.1, 120.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0)
assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 5)) == pytest.approx(1.0)
def test_wedge_reference():
t = ta.Wedge()
assert t.update((109.89, 110.0, 109.89, 109.89, 1.0, 0)) == pytest.approx(0.0)
assert t.update((90.0, 108.9, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((90.9, 100.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
assert t.update((94.0, 99.0, 94.0, 94.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((94.94, 103.0, 94.94, 94.94, 1.0, 4)) == pytest.approx(0.0)
assert t.update((92.7, 101.97, 92.7, 92.7, 1.0, 5)) == pytest.approx(-1.0)
def test_flag_pennant_reference():
t = ta.FlagPennant()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((130.0, 138.6, 130.0, 130.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((131.3, 143.0, 131.3, 131.3, 1.0, 4)) == pytest.approx(1.0)
def test_rectangle_range_reference():
t = ta.RectangleRange()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((99.99, 108.9, 99.99, 99.99, 1.0, 4)) == pytest.approx(1.0)
def test_cup_and_handle_reference():
t = ta.CupAndHandle()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((90.0, 118.8, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((90.9, 121.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
assert t.update((110.0, 119.79, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((111.1, 121.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0)
def test_abcd_reference():
t = ta.Abcd()
assert t.update((139.86, 140.0, 139.86, 139.86, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 138.6, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 124.7, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((84.7, 123.453, 84.7, 84.7, 1.0, 3)) == pytest.approx(0.0)
assert t.update((85.547, 93.17, 85.547, 85.547, 1.0, 4)) == pytest.approx(1.0)
def test_gartley_reference():
t = ta.Gartley()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((115.3, 138.6, 115.3, 115.3, 1.0, 3)) == pytest.approx(0.0)
assert t.update((116.453, 127.65, 116.453, 116.453, 1.0, 4)) == pytest.approx(0.0)
assert t.update((108.56, 126.3735, 108.56, 108.56, 1.0, 5)) == pytest.approx(0.0)
assert t.update((109.6456, 119.416, 109.6456, 109.6456, 1.0, 6)) == pytest.approx(1.0)
def test_butterfly_reference():
t = ta.Butterfly()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((108.6, 138.6, 108.6, 108.6, 1.0, 3)) == pytest.approx(0.0)
assert t.update((109.686, 128.0, 109.686, 109.686, 1.0, 4)) == pytest.approx(0.0)
assert t.update((79.8, 126.72, 79.8, 79.8, 1.0, 5)) == pytest.approx(0.0)
assert t.update((80.598, 87.78, 80.598, 80.598, 1.0, 6)) == pytest.approx(1.0)
def test_bat_reference():
t = ta.Bat()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((122.0, 138.6, 122.0, 122.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((123.22, 137.0, 123.22, 123.22, 1.0, 4)) == pytest.approx(0.0)
assert t.update((104.56, 135.63, 104.56, 104.56, 1.0, 5)) == pytest.approx(0.0)
assert t.update((105.6056, 115.016, 105.6056, 105.6056, 1.0, 6)) == pytest.approx(1.0)
def test_crab_reference():
t = ta.Crab()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((120.0, 138.6, 120.0, 120.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((121.2, 137.5, 121.2, 121.2, 1.0, 4)) == pytest.approx(0.0)
assert t.update((75.3, 136.125, 75.3, 75.3, 1.0, 5)) == pytest.approx(0.0)
assert t.update((76.053, 82.83, 76.053, 76.053, 1.0, 6)) == pytest.approx(1.0)
def test_shark_reference():
t = ta.Shark()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((88.0, 138.6, 88.0, 88.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((88.88, 186.8, 88.88, 88.88, 1.0, 4)) == pytest.approx(0.0)
assert t.update((100.0, 184.932, 100.0, 100.0, 1.0, 5)) == pytest.approx(0.0)
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 6)) == pytest.approx(1.0)
def test_cypher_reference():
t = ta.Cypher()
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((120.0, 138.6, 120.0, 120.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((121.2, 168.0, 121.2, 121.2, 1.0, 4)) == pytest.approx(0.0)
assert t.update((114.55, 166.32, 114.55, 114.55, 1.0, 5)) == pytest.approx(0.0)
assert t.update((115.6955, 126.005, 115.6955, 115.6955, 1.0, 6)) == pytest.approx(1.0)
def test_three_drives_reference():
t = ta.ThreeDrives()
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((101.0, 128.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((108.0, 126.72, 108.0, 108.0, 1.0, 3)) == pytest.approx(0.0)
assert t.update((109.08, 136.0, 109.08, 109.08, 1.0, 4)) == pytest.approx(0.0)
assert t.update((122.4, 134.64, 122.4, 122.4, 1.0, 5)) == pytest.approx(-1.0)
def test_fib_retracement_reference():
t = ta.FibRetracement()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
def test_fib_extension_reference():
t = ta.FibExtension()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((72.8, 58.6, 38.2, 0.0, -61.8))
def test_fib_projection_reference():
t = ta.FibProjection()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((160.0, 198.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((161.6, 190.0, 161.6, 161.6, 1.0, 2)) is None
assert t.update((171.0, 188.1, 171.0, 171.0, 1.0, 3)) == pytest.approx((165.28, 150.0, 125.28, 85.28))
def test_auto_fib_reference():
t = ta.AutoFib()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
def test_golden_pocket_reference():
t = ta.GoldenPocket()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((161.8, 163.4, 165.0))
def test_fib_confluence_reference():
t = ta.FibConfluence()
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((101.0, 160.0, 101.0, 101.0, 1.0, 2)) is None
assert t.update((144.0, 158.4, 144.0, 144.0, 1.0, 3)) == pytest.approx((137.64, 2.0))
def test_fib_fan_reference():
t = ta.FibFan()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((160.0, 190.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((100.0, 150.0, 100.0, 100.0, 1.0, 2)) is None
assert t.update((105.0, 110.0, 105.0, 105.0, 1.0, 3)) == pytest.approx((142.7, 125.0, 107.3))
def test_fib_arcs_reference():
t = ta.FibArcs()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((160.0, 190.0, 160.0, 160.0, 1.0, 1)) is None
assert t.update((100.0, 150.0, 100.0, 100.0, 1.0, 2)) is None
assert t.update((105.0, 110.0, 105.0, 105.0, 1.0, 3)) == pytest.approx((133.082181, 143.30127, 153.52037))
def test_fib_channel_reference():
t = ta.FibChannel()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((100.0, 190.0, 100.0, 100.0, 1.0, 1)) is None
assert t.update((108.0, 110.0, 108.0, 108.0, 1.0, 2)) is None
assert t.update((210.0, 220.0, 210.0, 210.0, 1.0, 3)) is None
assert t.update((150.0, 200.0, 150.0, 150.0, 1.0, 4)) == pytest.approx((226.666667, 160.746667, 120.0, 54.08))
def test_fib_time_zones_reference():
t = ta.FibTimeZones()
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
assert t.update((150.0, 190.0, 150.0, 150.0, 1.0, 1)) == pytest.approx((1.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 2)) == pytest.approx((1.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 3)) == pytest.approx((1.0, 2.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 4)) == pytest.approx((0.0, 1.0))
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 5)) == pytest.approx((1.0, 3.0))
def test_spread_ar1_coefficient_reference():
t = ta.SpreadAr1Coefficient(20)
assert t.update(1.0, 1.0) is None
# Spread a - b grows by exactly 1 each bar (unit root) => rho == 1.
a = np.array([2.0 * i for i in range(40)])
b = np.array([float(i) for i in range(40)])
out = ta.SpreadAr1Coefficient(20).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
def test_elder_ray_reference():
er = ta.ElderRay(3)
high = np.array([11.0, 13.0, 16.0])
low = np.array([9.0, 11.0, 13.0])
close = np.array([10.0, 12.0, 14.0])
out = er.batch(high, low, close)
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
# low 13 -> bear 1.
assert out[2][0] == pytest.approx(4.0)
assert out[2][1] == pytest.approx(1.0)
def test_imi_reference():
imi = ta.IMI(3)
open_ = np.array([10.0, 11.0, 10.0])
high = np.array([12.0, 12.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([11.0, 10.0, 12.0])
out = imi.batch(open_, high, low, close)
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
assert math.isnan(out[0])
assert math.isnan(out[1])
assert out[2] == pytest.approx(75.0)
def test_qstick_reference():
q = ta.Qstick(3)
open_ = np.array([10.0, 10.0, 10.0])
close = np.array([11.0, 11.0, 11.0])
out = q.batch(open_, close)
# Each body is close - open = 1; SMA(3) of [1, 1, 1] = 1.
assert math.isnan(out[0])
assert math.isnan(out[1])
assert out[2] == pytest.approx(1.0)
def test_ttm_trend_reference():
t = ta.TTM_TREND(3)
high = np.array([13.0, 13.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([12.0, 12.0, 12.0])
out = t.batch(high, low, close)
# Median (13 + 9) / 2 = 11; close 12 is above the SMA(3) reference -> +1.
assert math.isnan(out[0])
assert out[2] == pytest.approx(1.0)
def test_trend_strength_index_reference():
tsi = ta.TREND_STRENGTH_INDEX(10)
closes = np.arange(10, dtype=float)
out = tsi.batch(closes)
# A clean ramp is a perfect uptrend -> signed r^2 = +1.
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
def test_polarized_fractal_efficiency_reference():
pfe = ta.POLARIZED_FRACTAL_EFFICIENCY(5, 3)
closes = np.arange(20, dtype=float)
out = pfe.batch(closes)
# On a straight ramp the path equals the diagonal -> efficiency 1 -> +100.
assert math.isclose(out[-1], 100.0, abs_tol=1e-9)
def test_wave_pm_reference():
wpm = ta.WAVE_PM(10, 3)
closes = np.arange(60, dtype=float) * 5.0
out = wpm.batch(closes)
# Constant-slope ramp: momentum equals its energy -> 100 * (1 - e^-0.5).
baseline = 100.0 * (1.0 - math.exp(-0.5))
assert math.isclose(out[-1], baseline, abs_tol=1e-9)
def test_gator_oscillator_reference():
g = ta.GatorOscillator(13, 8, 5)
n = 40
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 10.0)
out = g.batch(high, low, close)
# Constant median collapses all three Alligator lines -> both bars zero.
assert out[-1][0] == pytest.approx(0.0)
assert out[-1][1] == pytest.approx(0.0)
def test_kase_permission_stochastic_reference():
k = ta.KasePermissionStochastic(4, 2)
n = 20
flat = np.full(n, 10.0)
out = k.batch(flat, flat, flat)
# HH == LL -> raw %K defaults to the neutral 50 -> both lines at 50.
assert out[-1][0] == pytest.approx(50.0)
assert out[-1][1] == pytest.approx(50.0)
def test_tsf_oscillator_reference():
t = ta.TsfOscillator(3)
assert t.update(1.0) is None
assert t.update(2.0) is None
assert t.update(9.0) == pytest.approx(-33.33333333333333)
def test_macd_histogram_reference():
# On a constant-slope ramp the MACD line is flat once seeded, so the
# signal EMA catches up and the histogram collapses to 0.
t = ta.MacdHistogram(3, 6, 3)
for i in range(7):
assert t.update(100.0 + i * 2.0) is None
assert t.update(100.0 + 7 * 2.0) == pytest.approx(0.0, abs=1e-9)
def test_ppo_histogram_reference():
# PPO divides the EMA gap by the slow EMA, so on the same ramp the ratio
# keeps drifting and the histogram stays non-zero.
t = ta.PpoHistogram(3, 6, 3)
for i in range(7):
assert t.update(100.0 + i * 2.0) is None
assert t.update(100.0 + 7 * 2.0) == pytest.approx(-0.052098, abs=1e-6)
def test_ewma_volatility_reference():
t = ta.EwmaVolatility(0.94)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.09531017980432493)
assert t.update(99.0) == pytest.approx(0.0959428936787596)
def test_garch11_reference():
t = ta.Garch11(0.000002, 0.1, 0.88)
assert t.update(100.0) is None
assert t.update(110.0) == pytest.approx(0.009999999999999995)
assert t.update(99.0) == pytest.approx(0.031597516317477786)
def test_volatility_cone_reference():
t = ta.VolatilityCone(20, 60)
def test_quartile_bands_reference():
t = ta.QuartileBands(4)
assert t.update(40.0) is None
assert t.update(30.0) is None
assert t.update(20.0) is None
assert t.update(10.0) == pytest.approx((32.5, 25.0, 17.5))
def test_bomar_bands_reference():
t = ta.BomarBands(4, 0.85)
assert t.update(100.0) is None
assert t.update(102.0) is None
assert t.update(98.0) is None
assert t.update(104.0) == pytest.approx((104.0, 101.0, 98.0))
def test_median_channel_reference():
t = ta.MedianChannel(5, 2.0)
assert t.update(1.0) is None
assert t.update(2.0) is None
assert t.update(3.0) is None
assert t.update(4.0) is None
assert t.update(5.0) == pytest.approx((5.0, 3.0, 1.0))
def test_projection_bands_reference():
t = ta.ProjectionBands(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx((12.5, 11.25, 10.0))
def test_projection_oscillator_reference():
# Same window as ProjectionBands: upper 12.5, lower 10; close 11 -> 40.
t = ta.ProjectionOscillator(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx(40.0)
def test_kase_devstop_reference():
t = ta.KaseDevStop(3, 1.0)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
assert t.update((101.0, 102.0, 100.0, 101.0, 1.0, 1)) is None
assert t.update((102.0, 103.0, 101.0, 102.0, 1.0, 2)) is None
assert t.update((102.5, 104.0, 102.0, 103.0, 1.0, 3)) == pytest.approx((101.0, 1.0))
def _stop_candles(n):
# Gently rising, valid OHLC: high >= open/close, low <= open/close.
return [(100.0 + i, 101.5 + i, 98.5 + i, 100.5 + i, 1.0, i) for i in range(n)]
def test_elder_safezone_reference():
t = ta.ElderSafeZone(14, 2.0)
candles = _stop_candles(15)
for c in candles[:14]:
assert t.update(c) is None
assert t.update(candles[14]) == pytest.approx((112.5, 1.0))
def test_atr_ratchet_reference():
t = ta.AtrRatchet(14, 4.0, 0.1)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((101.5, 1.0))
def test_nrtr_reference():
t = ta.Nrtr(2.0)
assert t.update((100.0, 100.0, 100.0, 100.0, 1.0, 0)) == pytest.approx((98.0, 1.0))
def test_time_based_stop_reference():
t = ta.TimeBasedStop(5)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) == pytest.approx(0.2)
def test_modified_ma_stop_reference():
t = ta.ModifiedMaStop(14)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
def test_volume_rsi_reference():
t = ta.VolumeRsi(14)
def test_twiggs_money_flow_reference():
t = ta.TwiggsMoneyFlow(21)
def test_trade_volume_index_reference():
t = ta.TradeVolumeIndex(0.25)
def test_intraday_intensity_reference():
t = ta.IntradayIntensity()
def test_better_volume_reference():
t = ta.BetterVolume(14)
def test_volume_weighted_macd_reference():
t = ta.VolumeWeightedMacd(12, 26, 9)
def test_kendall_tau_reference():
t = ta.KendallTau(20)
def test_central_pivot_range_reference():
t = ta.CentralPivotRange()
assert t.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) == pytest.approx((101.66666666666667, 103.33333333333334, 100.0))
def test_murrey_math_lines_reference():
t = ta.MurreyMathLines(4)
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 0)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 1)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 2)) is None
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 3)) == pytest.approx((180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, 100.0))
def test_andrews_pitchfork_reference():
t = ta.AndrewsPitchfork(2)
# Warmup: no pitchfork until three alternating swing pivots are confirmed.
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
def test_volume_weighted_sr_reference():
t = ta.VolumeWeightedSr(3)
assert t.update((100.0, 102.0, 98.0, 100.0, 1.0, 0)) is None
assert t.update((100.0, 104.0, 96.0, 100.0, 1.0, 1)) is None
assert t.update((100.0, 106.0, 94.0, 100.0, 1.0, 2)) == pytest.approx((96.0, 104.0))
def test_pivot_reversal_reference():
t = ta.PivotReversal(1, 1)
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 0)) is None
assert t.update((11.5, 12.0, 11.0, 11.5, 1.0, 1)) is None
# Pivot high = 12 confirmed; close 9.5 has not crossed it.
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 2)) == pytest.approx(0.0)
assert t.update((9.0, 11.0, 9.0, 9.0, 1.0, 3)) == pytest.approx(0.0)
# Close 13 > pivot high 12 with prev close 9 below it -> bullish reversal.
assert t.update((13.0, 14.0, 12.5, 13.0, 1.0, 4)) == pytest.approx(1.0)
def test_td_camouflage_reference():
t = ta.TDCamouflage()
assert t.update((10.0, 11.0, 8.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((9.0, 10.0, 7.0, 9.5, 1.0, 1)) == pytest.approx(1.0)
def test_td_clop_reference():
t = ta.TDClop()
assert t.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((9.0, 13.0, 8.0, 12.0, 1.0, 1)) == pytest.approx(1.0)
def test_td_clopwin_reference():
t = ta.TDClopwin()
assert t.update((10.0, 15.0, 9.0, 14.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((11.0, 14.0, 10.0, 13.0, 1.0, 1)) == pytest.approx(1.0)
def test_td_propulsion_reference():
t = ta.TDPropulsion()
assert t.update((9.5, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((10.5, 12.0, 10.0, 11.5, 1.0, 1)) == pytest.approx(1.0)
def test_td_trap_reference():
t = ta.TDTrap()
assert t.update((100.0, 110.0, 90.0, 100.0, 1.0, 0)) == pytest.approx(0.0)
assert t.update((101.5, 108.0, 95.0, 102.0, 1.0, 1)) == pytest.approx(0.0)
assert t.update((106.0, 112.0, 100.0, 109.0, 1.0, 2)) == pytest.approx(1.0)
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)
# --- Lifecycle ------------------------------------------------------------
@@ -2671,6 +3595,7 @@ def test_orderbook_indicators_streaming_equals_batch():
ta.Microprice,
ta.QuotedSpread,
ta.DepthSlope,
lambda: ta.OrderFlowImbalance(10),
):
batch = make().batch(snaps)
streamer = make()
@@ -2690,6 +3615,9 @@ def test_tradeflow_indicators_streaming_equals_batch():
ta.SignedVolume,
ta.CumulativeVolumeDelta,
lambda: ta.TradeImbalance(5),
lambda: ta.Vpin(8.0, 5),
lambda: ta.AmihudIlliquidity(14),
lambda: ta.RollMeasure(14),
):
batch = make().batch(price, size, is_buy)
streamer = make()
+132
View File
@@ -0,0 +1,132 @@
"""Streaming-vs-batch equivalence and reference values for the Seasonality &
Session family.
These indicators read the full candle (including ``timestamp``), so they have a
dedicated test rather than joining the timestamp-less parametrize harness in
``test_new_indicators.py``.
"""
import numpy as np
import pytest
import wickra as ta
HOUR_MS = 3_600_000
@pytest.fixture(scope="module")
def candle_columns():
"""240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
n = 240
t = np.arange(n, dtype=np.float64)
close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
open_ = close + np.sin(t * 0.5) * 0.5
high = np.maximum(open_, close) + 1.0
low = np.minimum(open_, close) - 1.0
volume = 1000.0 + (t % 24) * 50.0
timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
return open_, high, low, close, volume, timestamp
def _candles(cols):
open_, high, low, close, volume, timestamp = cols
return [
(open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
for i in range(len(close))
]
def _check_scalar(make, cols):
candles = _candles(cols)
a, b = make(), make()
stream = np.array(
[np.nan if (v := a.update(c)) is None else v for c in candles],
dtype=np.float64,
)
batch = np.asarray(b.batch(*cols))
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
def _check_matrix(make, k, cols):
candles = _candles(cols)
a, b = make(), make()
rows = []
for c in candles:
out = a.update(c)
rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
stream = np.vstack(rows)
batch = np.asarray(b.batch(*cols))
assert batch.shape == (len(candles), k)
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
SCALAR = [
lambda: ta.SessionVwap(0),
lambda: ta.OvernightGap(0),
lambda: ta.SeasonalZScore(0),
lambda: ta.AverageDailyRange(3, 0),
lambda: ta.TurnOfMonth(3, 1, 0),
]
MATRIX = [
(lambda: ta.SessionHighLow(0), 2),
(lambda: ta.SessionRange(0), 3),
(lambda: ta.OvernightIntradayReturn(0), 2),
(lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
(lambda: ta.IntradayVolatilityProfile(12, 0), 12),
(lambda: ta.VolumeByTimeProfile(24, 0), 24),
(lambda: ta.DayOfWeekProfile(0), 7),
]
@pytest.mark.parametrize("make", SCALAR)
def test_scalar_streaming_equals_batch(make, candle_columns):
_check_scalar(make, candle_columns)
@pytest.mark.parametrize("make,k", MATRIX)
def test_matrix_streaming_equals_batch(make, k, candle_columns):
_check_matrix(make, k, candle_columns)
def test_session_vwap_reference():
vwap = ta.SessionVwap(0)
# typical = close for a flat candle; volume-weighted within the day.
v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
assert v1 == pytest.approx(100.0)
v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
assert v2 == pytest.approx(107.5)
# New day re-anchors.
v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
assert v3 == pytest.approx(200.0)
def test_overnight_gap_reference():
gap = ta.OvernightGap(0)
assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
assert g == pytest.approx(0.05)
def test_session_high_low_reference():
shl = ta.SessionHighLow(0)
shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
assert out == (108.0, 99.0)
def test_volume_by_time_profile_reference():
prof = ta.VolumeByTimeProfile(24, 0)
out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
assert out[1] == pytest.approx(500.0)
assert out[0] == pytest.approx(0.0)
def test_rejects_zero_buckets():
with pytest.raises(ValueError):
ta.TimeOfDayReturnProfile(0, 0)
def test_average_daily_range_rejects_zero_period():
with pytest.raises(ValueError):
ta.AverageDailyRange(0, 0)
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "wickra-bench"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
description = "Internal cross-library benchmark harness (not published)."
[lints]
workspace = true
[dev-dependencies]
wickra = { path = "../wickra" }
wickra-data = { path = "../wickra-data" }
criterion = { workspace = true }
kand = "0.2.2"
ta = "0.5.0"
yata = "0.7.0"
[[bench]]
name = "cross_lib"
harness = false
+699
View File
@@ -0,0 +1,699 @@
//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`.
//!
//! All four are pure-Rust technical-analysis crates, so this is a like-for-like
//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact
//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`.
//!
//! Two arenas, kept honest:
//!
//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`),
//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand`
//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually
//! here, seeded from `kand`'s own batch output (the seed is computed outside the
//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are
//! exposed through a heavier signal-oriented indicator API, not a raw-value method,
//! so they are intentionally left out rather than compared unfairly.
//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`)
//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API;
//! ta-rs and yata are streaming-only and are deliberately absent from this arena.
//!
//! Run: `cargo bench -p wickra-bench`
// Each indicator's benchmark group spells out every library arm explicitly, which
// runs a few groups over the 100-line lint threshold; that verbosity is the point.
#![allow(clippy::too_many_lines)]
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wickra::{Atr, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
use wickra_data::csv::CandleReader;
use yata::prelude::Method;
const SIZES: &[usize] = &[1_000, 10_000, 50_000];
const SMA_PERIOD: usize = 20;
const EMA_PERIOD: usize = 20;
const RSI_PERIOD: usize = 14;
const ATR_PERIOD: usize = 14;
const BB_PERIOD: usize = 20;
const BB_DEV: f64 = 2.0;
const MACD_FAST: usize = 12;
const MACD_SLOW: usize = 26;
const MACD_SIGNAL: usize = 9;
fn load_candles() -> Vec<Candle> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/data/btcusdt-1m.csv"
);
CandleReader::open(path)
.expect("dataset present")
.read_all()
.expect("valid OHLCV rows")
}
/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`.
fn window_mean(series: &[f64], period: usize) -> f64 {
series[..period].iter().sum::<f64>() / period as f64
}
fn sma_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("sma_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Sma::new(SMA_PERIOD).unwrap();
black_box(ind.batch_nan(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, SMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for idx in SMA_PERIOD..series.len() {
prev = kand::ohlcv::sma::sma_inc(
prev,
series[idx],
series[idx - SMA_PERIOD],
SMA_PERIOD,
)
.unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn ema_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("ema_20");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Ema::new(EMA_PERIOD).unwrap();
black_box(ind.batch_nan(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let seed = window_mean(series, EMA_PERIOD);
bencher.iter(|| {
let mut prev = seed;
for &price in &series[EMA_PERIOD..] {
prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap();
black_box(prev);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut out = vec![0.0; series.len()];
kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap();
black_box(&out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind =
ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("yata/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap();
for price in series {
black_box(ind.next(price));
}
});
},
);
}
group.finish();
}
fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("rsi_14");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
black_box(ind.batch_nan(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Wilder seed: simple average of the first `period` gains and losses.
let mut gain = 0.0;
let mut loss = 0.0;
for idx in 1..=RSI_PERIOD {
let delta = series[idx] - series[idx - 1];
if delta > 0.0 {
gain += delta;
} else {
loss -= delta;
}
}
let seed_gain = gain / RSI_PERIOD as f64;
let seed_loss = loss / RSI_PERIOD as f64;
bencher.iter(|| {
let mut avg_gain = seed_gain;
let mut avg_loss = seed_loss;
let mut prev_price = series[RSI_PERIOD];
for &price in &series[RSI_PERIOD + 1..] {
let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc(
price, prev_price, avg_gain, avg_loss, RSI_PERIOD,
)
.unwrap();
avg_gain = next_gain;
avg_loss = next_loss;
prev_price = price;
black_box(rsi);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut rsi = vec![0.0; series.len()];
let mut avg_gain = vec![0.0; series.len()];
let mut avg_loss = vec![0.0; series.len()];
kand::ohlcv::rsi::rsi(
series,
RSI_PERIOD,
&mut rsi,
&mut avg_gain,
&mut avg_loss,
)
.unwrap();
black_box(&rsi);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn macd_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("macd_12_26_9");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = MacdIndicator::classic();
black_box(ind.batch_macd(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed the fast/slow/signal EMAs from kand's own warmed-up batch state.
let lookback =
kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap();
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
let seed_fast = fast_ema[lookback];
let seed_slow = slow_ema[lookback];
let seed_signal = signal_line[lookback];
bencher.iter(|| {
// macd_inc returns (macd, signal, hist) but not the new EMAs, so the
// fast/slow/signal state is threaded with kand's own ema_inc primitive.
let mut prev_fast = seed_fast;
let mut prev_slow = seed_slow;
let mut prev_signal = seed_signal;
for &price in &series[lookback + 1..] {
let fast =
kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap();
let slow =
kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap();
let macd = fast - slow;
let signal =
kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None)
.unwrap();
prev_fast = fast;
prev_slow = slow;
prev_signal = signal;
black_box((macd, signal, macd - signal));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut macd_line = vec![0.0; series.len()];
let mut signal_line = vec![0.0; series.len()];
let mut histogram = vec![0.0; series.len()];
let mut fast_ema = vec![0.0; series.len()];
let mut slow_ema = vec![0.0; series.len()];
kand::ohlcv::macd::macd(
series,
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
&mut macd_line,
&mut signal_line,
&mut histogram,
&mut fast_ema,
&mut slow_ema,
)
.unwrap();
black_box(&macd_line);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new(
MACD_FAST,
MACD_SLOW,
MACD_SIGNAL,
)
.unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
let mut group = crit.benchmark_group("bollinger_20_2");
for &len in SIZES {
let len = len.min(closes.len());
let series: &[f64] = &closes[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ind.update(price));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
black_box(ind.batch_bands(series));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
// Seed running sma/sum/sum_sq from kand's batch state at the warmup edge.
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
let seed_sma = sma[BB_PERIOD - 1];
let seed_sum = sum[BB_PERIOD - 1];
let seed_sum_sq = sum_sq[BB_PERIOD - 1];
bencher.iter(|| {
let mut prev_sma = seed_sma;
let mut prev_sum = seed_sum;
let mut prev_sum_sq = seed_sum_sq;
for idx in BB_PERIOD..series.len() {
let result = kand::ohlcv::bbands::bbands_inc(
series[idx],
prev_sma,
prev_sum,
prev_sum_sq,
series[idx - BB_PERIOD],
BB_PERIOD,
BB_DEV,
BB_DEV,
)
.unwrap();
prev_sma = result.1;
prev_sum = result.4;
prev_sum_sq = result.5;
black_box((result.0, result.1, result.2));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut upper = vec![0.0; series.len()];
let mut middle = vec![0.0; series.len()];
let mut lower = vec![0.0; series.len()];
let mut sma = vec![0.0; series.len()];
let mut variance = vec![0.0; series.len()];
let mut sum = vec![0.0; series.len()];
let mut sum_sq = vec![0.0; series.len()];
kand::ohlcv::bbands::bbands(
series,
BB_PERIOD,
BB_DEV,
BB_DEV,
&mut upper,
&mut middle,
&mut lower,
&mut sma,
&mut variance,
&mut sum,
&mut sum_sq,
)
.unwrap();
black_box(&upper);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
for &price in series {
black_box(ta::Next::next(&mut ind, price));
}
});
},
);
}
group.finish();
}
fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
let mut group = crit.benchmark_group("atr_14");
for &len in SIZES {
let len = len.min(candles.len());
let series: &[Candle] = &candles[..len];
group.throughput(Throughput::Elements(len as u64));
group.bench_with_input(
BenchmarkId::new("wickra/stream", len),
&series,
|bencher, &series| {
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
for &candle in series {
black_box(ind.update(candle));
}
});
},
);
group.bench_with_input(
BenchmarkId::new("wickra/batch", len),
&series,
|bencher, &series| {
// Column extraction is outside the timed loop, mirroring kand's arm.
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut ind = Atr::new(ATR_PERIOD).unwrap();
black_box(ind.batch_atr(&high, &low, &close));
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/stream", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
// Seed prev_atr from kand's batch ATR at the first valid index (= period).
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
let seed_atr = atr_out[ATR_PERIOD];
bencher.iter(|| {
let mut prev_atr = seed_atr;
for idx in ATR_PERIOD + 1..series.len() {
prev_atr = kand::ohlcv::atr::atr_inc(
high[idx],
low[idx],
close[idx - 1],
prev_atr,
ATR_PERIOD,
)
.unwrap();
black_box(prev_atr);
}
});
},
);
group.bench_with_input(
BenchmarkId::new("kand/batch", len),
&series,
|bencher, &series| {
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
bencher.iter(|| {
let mut atr_out = vec![0.0; series.len()];
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
black_box(&atr_out);
});
},
);
group.bench_with_input(
BenchmarkId::new("ta-rs/stream", len),
&series,
|bencher, &series| {
let items: Vec<ta::DataItem> = series
.iter()
.map(|candle| {
ta::DataItem::builder()
.open(candle.open)
.high(candle.high)
.low(candle.low)
.close(candle.close)
.volume(candle.volume)
.build()
.unwrap()
})
.collect();
bencher.iter(|| {
let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap();
for item in &items {
black_box(ta::Next::next(&mut ind, item));
}
});
},
);
}
group.finish();
}
fn benches(crit: &mut Criterion) {
let candles = load_candles();
let closes: Vec<f64> = candles.iter().map(|candle| candle.close).collect();
sma_group(crit, &closes);
ema_group(crit, &closes);
rsi_group(crit, &closes);
macd_group(crit, &closes);
bbands_group(crit, &closes);
atr_group(crit, &candles);
}
criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches);
criterion_main!(cross_lib);
+6
View File
@@ -0,0 +1,6 @@
//! Internal cross-library benchmark harness for Wickra.
//!
//! This crate is `publish = false`. It exists only to host the Criterion
//! benchmark in `benches/cross_lib.rs`, which compares Wickra against the
//! Rust technical-analysis crates `kand`, `ta` (ta-rs) and `yata` on an
//! identical candle series. It deliberately carries no library code.
+203
View File
@@ -0,0 +1,203 @@
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
//!
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
//! relevant exchange session rather than UTC. This module turns an epoch
//! millisecond instant into its civil fields using Howard Hinnant's
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
//!
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CivilTime {
/// Proleptic Gregorian year (can be negative for instants before year 1).
pub(crate) year: i64,
/// Month of year, `1..=12`.
pub(crate) month: u32,
/// Day of month, `1..=31`.
pub(crate) day: u32,
/// Hour of day, `0..=23`.
pub(crate) hour: u32,
/// Minute of hour, `0..=59`.
pub(crate) minute: u32,
/// Day of week with Monday as `0` through Sunday as `6`.
pub(crate) weekday: u32,
}
impl CivilTime {
/// Minute of day, `0..=1439`.
pub(crate) const fn minute_of_day(&self) -> u32 {
self.hour * 60 + self.minute
}
}
/// Decompose an epoch-millisecond instant into local civil fields.
///
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
let days = local_secs.div_euclid(86_400);
let secs_of_day = local_secs.rem_euclid(86_400);
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let (year, month, day) = civil_from_days(days);
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
let weekday = (days + 3).rem_euclid(7) as u32;
CivilTime {
year,
month,
day,
hour,
minute,
weekday,
}
}
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
///
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if month <= 2 { year + 1 } else { year }, month, day)
}
/// Whether `year` is a Gregorian leap year.
pub(crate) const fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// Number of days in `month` (`1..=12`) of `year`.
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap(year) {
29
} else {
28
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_zero_is_thursday_midnight() {
let t = civil_from_timestamp(0, 0);
assert_eq!(
t,
CivilTime {
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
weekday: 3, // Thursday
}
);
assert_eq!(t.minute_of_day(), 0);
}
#[test]
fn known_utc_instant_mid_year() {
// 2021-06-15 13:45:00 UTC = 1623764700 s.
let t = civil_from_timestamp(1_623_764_700_000, 0);
assert_eq!(t.year, 2021);
assert_eq!(t.month, 6);
assert_eq!(t.day, 15);
assert_eq!(t.hour, 13);
assert_eq!(t.minute, 45);
assert_eq!(t.weekday, 1); // Tuesday
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
}
#[test]
fn new_year_2021_is_friday() {
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
let t = civil_from_timestamp(1_609_459_200_000, 0);
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
assert_eq!(t.weekday, 4); // Friday
}
#[test]
fn positive_offset_rolls_to_next_day() {
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
let t = civil_from_timestamp(base, 60);
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
assert_eq!((t.hour, t.minute), (0, 30));
assert_eq!(t.weekday, 5); // Saturday
}
#[test]
fn negative_offset_rolls_to_previous_day() {
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
let base = 1_609_459_200_000 + 30 * 60 * 1000;
let t = civil_from_timestamp(base, -60);
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
assert_eq!((t.hour, t.minute), (23, 30));
assert_eq!(t.weekday, 3); // Thursday
}
#[test]
fn sub_epoch_millis_floor_correctly() {
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
let t = civil_from_timestamp(-1, 0);
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
assert_eq!((t.hour, t.minute), (23, 59));
assert_eq!(t.weekday, 2); // Wednesday
}
#[test]
fn far_negative_day_count_hits_pre_era_branch() {
// A day count below -719468 drives `z + 719468` negative, exercising the
// `z - 146096` era branch in civil_from_days (year < 1).
let (year, month, day) = civil_from_days(-1_000_000);
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
// Gregorian, astronomical year numbering where year 0 exists).
assert_eq!((year, month, day), (-768, 2, 4));
}
#[test]
fn leap_year_rules() {
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
}
#[test]
fn days_in_month_all_cases() {
assert_eq!(days_in_month(2023, 1), 31);
assert_eq!(days_in_month(2023, 4), 30);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 12), 31);
assert_eq!(days_in_month(2023, 11), 30);
}
#[test]
fn leap_day_decodes() {
// 2024-02-29 12:00 UTC.
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
let t = civil_from_timestamp(secs * 1000, 0);
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
assert_eq!(t.hour, 12);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! AB=CD harmonic pattern.
use crate::indicators::pattern_swing::{approx_equal, ratios_in, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// AB=CD — the simplest four-point harmonic pattern: an A→B leg, a B→C
/// retracement, and a C→D leg that mirrors A→B in length:
///
/// ```text
/// BC / AB ∈ [0.382, 0.886] (C retraces AB)
/// CD / BC ∈ [1.13, 2.618] (D extends BC)
/// AB ≈ CD (within 10%) (the two legs are equal — the defining symmetry)
/// ```
///
/// Read from the last four confirmed pivots `A-B-C-D`. Output is `+1.0`
/// (bullish, D a swing low), `-1.0` (bearish, D a swing high), or `0.0`; never
/// `None`. See `crates/wickra-core/src/indicators/abcd.rs`.
#[derive(Debug, Clone)]
pub struct Abcd {
swing: SwingTracker,
has_emitted: bool,
}
impl Abcd {
/// Construct a new AB=CD detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for Abcd {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Abcd {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 4 {
return Some(0.0);
}
let len = pivots.len();
let pa = pivots[len - 4];
let pb = pivots[len - 3];
let pc = pivots[len - 2];
let pd = pivots[len - 1];
let ab = (pb.price - pa.price).abs();
let bc = (pc.price - pb.price).abs();
let cd = (pd.price - pc.price).abs();
let ratios_ok = ratios_in(&[(bc / ab, 0.382, 0.886), (cd / bc, 1.13, 2.618)]);
let legs_equal = approx_equal(ab, cd, 0.10);
if ratios_ok && legs_equal {
return Some(if pd.direction < 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Abcd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Abcd::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Abcd::new();
assert_eq!(indicator.name(), "Abcd");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!Abcd::default().is_ready());
}
#[test]
fn bullish_abcd_is_plus_one() {
// AB = 40 down, BC = 24.7 up (0.618), CD = 40 down → AB = CD.
let out = run(&[140.0, 100.0, 124.7, 84.7]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_abcd_is_minus_one() {
let out = run(&[150.0, 100.0, 140.0, 115.3, 155.3]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn unequal_legs_do_not_trigger() {
// CD (82) far longer than AB (40) → not an AB=CD.
let out = run(&[150.0, 100.0, 140.0, 118.0, 200.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Abcd::new();
for c in candles_for_pivots(&[140.0, 100.0, 124.7]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[140.0, 100.0, 124.7, 84.7]);
let mut a = Abcd::new();
let mut b = Abcd::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,245 @@
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
/// plain SMA, so it leads in trends and stays calm in chop.
///
/// ```text
/// TP = (high + low + close) / 3
/// ER = |TP_t TP_oldest| / Σ |ΔTP| over the window (0..1)
/// sc = ( ER·(2/3 2/31) + 2/31 )²
/// mean += sc·(TP_t mean) (adaptive centre, seeded with SMA)
/// MD = mean(|TP_i mean|) over the window (mean deviation)
/// CCI = (TP_t mean) / (0.015 · MD)
/// ```
///
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
/// lets the centre line accelerate toward price in a clean trend (so the CCI
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
/// The `0.015` scaling keeps Lambert's convention that roughly 7080% of readings
/// fall in `[100, +100]`.
///
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
///
/// let mut indicator = AdaptiveCci::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveCci {
period: usize,
window: VecDeque<f64>,
mean: Option<f64>,
last: Option<f64>,
}
impl AdaptiveCci {
/// Construct an adaptive CCI with the given `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
/// path of at least one step).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "adaptive CCI needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
mean: None,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for AdaptiveCci {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tp = candle.typical_price();
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(tp);
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
// Efficiency ratio over the window.
let oldest = self.window[0];
let direction = (tp - oldest).abs();
let mut path = 0.0;
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
path += (pair[1] - pair[0]).abs();
}
let er = if path > 0.0 {
(direction / path).clamp(0.0, 1.0)
} else {
0.0
};
let fast = 2.0 / 3.0;
let slow = 2.0 / 31.0;
let sc = (er * (fast - slow) + slow).powi(2);
let mean = match self.mean {
None => self.window.iter().sum::<f64>() / n,
Some(prev) => prev + sc * (tp - prev),
};
self.mean = Some(mean);
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
let cci = if md > 0.0 {
(tp - mean) / (0.015 * md)
} else {
0.0
};
self.last = Some(cci);
Some(cci)
}
fn reset(&mut self) {
self.window.clear();
self.mean = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveCci"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(tp: f64) -> Candle {
// open=high=low=close=tp -> typical price == tp.
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
}
#[test]
fn rejects_invalid_period() {
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
assert!(matches!(
AdaptiveCci::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let c = AdaptiveCci::new(20).unwrap();
assert_eq!(c.period(), 20);
assert_eq!(c.warmup_period(), 20);
assert_eq!(c.name(), "AdaptiveCci");
assert!(!c.is_ready());
assert_eq!(c.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut c = AdaptiveCci::new(4).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
let out = c.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn uptrend_is_positive() {
let mut c = AdaptiveCci::new(10).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
}
#[test]
fn downtrend_is_negative() {
let mut c = AdaptiveCci::new(10).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
}
#[test]
fn flat_window_is_zero() {
let mut c = AdaptiveCci::new(5).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
for v in c.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn reset_clears_state() {
let mut c = AdaptiveCci::new(5).unwrap();
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
c.batch(&candles);
assert!(c.is_ready());
c.reset();
assert!(!c.is_ready());
assert_eq!(c.value(), None);
assert_eq!(c.update(candle(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
let mut b = AdaptiveCci::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,344 @@
//! Ehlers' Adaptive Laguerre Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial
/// smoother whose damping factor `gamma` is recomputed every bar from how well
/// the filter is currently tracking price.
///
/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi),
/// but instead of a fixed `gamma` the filter adapts: it measures the recent
/// absolute error `|price filter|`, normalises those errors across a window of
/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price
/// is tracking smoothly the errors are small and uniform (low `gamma`, fast
/// response); when price jumps, the spread of errors widens and `gamma` rises,
/// slowing the filter to reject the noise.
///
/// ```text
/// diff_t = |price_t filter_{t-1}|
/// over the last `period` diffs:
/// HH = max(diff), LL = min(diff)
/// norm_i = (diff_i LL) / (HH LL) (0 if HH == LL)
/// gamma = median(norm)
/// alpha = 1 gamma
/// L0_t = alpha·price_t + gamma·L0_{t-1}
/// L1_t = gamma·L0_t + L0_{t-1} + gamma·L1_{t-1}
/// L2_t = gamma·L1_t + L1_{t-1} + gamma·L2_{t-1}
/// L3_t = gamma·L2_t + L2_{t-1} + gamma·L3_{t-1}
/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6
/// ```
///
/// The output is a smoothed price on the same scale as the input. The first
/// emission lands once the error window holds `period` values.
///
/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis
/// of Stocks & Commodities, 2007.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveLaguerreFilter};
///
/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveLaguerreFilter {
period: usize,
l0: f64,
l1: f64,
l2: f64,
l3: f64,
/// Previous filter output, or `None` before the first bar.
filter: Option<f64>,
/// The last `period` absolute errors `|price filter|`.
diffs: VecDeque<f64>,
}
impl AdaptiveLaguerreFilter {
/// Construct a new adaptive Laguerre filter with the given error-window
/// length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
l0: 0.0,
l1: 0.0,
l2: 0.0,
l3: 0.0,
filter: None,
diffs: VecDeque::with_capacity(period),
})
}
/// Configured error-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the error window is full.
pub fn value(&self) -> Option<f64> {
if self.diffs.len() == self.period {
self.filter
} else {
None
}
}
/// Median of the normalised errors currently in the window. Returns `0.0`
/// when every error is equal (e.g. during a constant warmup), which makes
/// the filter maximally fast.
fn adaptive_gamma(&self) -> f64 {
let mut hh = f64::MIN;
let mut ll = f64::MAX;
for &d in &self.diffs {
if d > hh {
hh = d;
}
if d < ll {
ll = d;
}
}
let range = hh - ll;
if range <= 0.0 {
return 0.0;
}
let mut norm: Vec<f64> = self.diffs.iter().map(|&d| (d - ll) / range).collect();
// `total_cmp` never panics — under pathological (e.g. overflowing) fuzz
// inputs a normalised error can be non-finite; a total order keeps the
// sort sound where `partial_cmp` would return `None`.
norm.sort_by(f64::total_cmp);
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
}
}
impl Indicator for AdaptiveLaguerreFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
// Absolute tracking error against the previous filter (0 on the first
// bar, where there is no prior filter value).
let diff = self.filter.map_or(0.0, |f| (price - f).abs());
if self.diffs.len() == self.period {
self.diffs.pop_front();
}
self.diffs.push_back(diff);
let gamma = self.adaptive_gamma();
let alpha = 1.0 - gamma;
let l0 = alpha * price + gamma * self.l0;
let l1 = -gamma * l0 + self.l0 + gamma * self.l1;
let l2 = -gamma * l1 + self.l1 + gamma * self.l2;
let l3 = -gamma * l2 + self.l2 + gamma * self.l3;
self.l0 = l0;
self.l1 = l1;
self.l2 = l2;
self.l3 = l3;
let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0;
self.filter = Some(filter);
self.value()
}
fn reset(&mut self) {
self.l0 = 0.0;
self.l1 = 0.0;
self.l2 = 0.0;
self.l3 = 0.0;
self.filter = None;
self.diffs.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"AdaptiveLaguerre"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: replays the exact recurrence from scratch.
fn naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let mut filter: Option<f64> = None;
let mut diffs: Vec<f64> = Vec::new();
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let diff = filter.map_or(0.0, |f: f64| (price - f).abs());
diffs.push(diff);
if diffs.len() > period {
diffs.remove(0);
}
let hh = diffs.iter().copied().fold(f64::MIN, f64::max);
let ll = diffs.iter().copied().fold(f64::MAX, f64::min);
let range = hh - ll;
let gamma = if range <= 0.0 {
0.0
} else {
let mut norm: Vec<f64> = diffs.iter().map(|&d| (d - ll) / range).collect();
norm.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
};
let alpha = 1.0 - gamma;
let n0 = alpha * price + gamma * l0;
let n1 = -gamma * n0 + l0 + gamma * l1;
let n2 = -gamma * n1 + l1 + gamma * l2;
let n3 = -gamma * n2 + l2 + gamma * l3;
l0 = n0;
l1 = n1;
l2 = n2;
l3 = n3;
let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
filter = Some(f);
out.push(if diffs.len() == period { Some(f) } else { None });
}
out
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
AdaptiveLaguerreFilter::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let alf = AdaptiveLaguerreFilter::new(13).unwrap();
assert_eq!(alf.period(), 13);
assert_eq!(alf.warmup_period(), 13);
assert_eq!(alf.name(), "AdaptiveLaguerre");
}
#[test]
fn warmup_returns_none_until_window_full() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
assert_eq!(alf.update(10.0), None);
assert_eq!(alf.update(11.0), None);
assert!(alf.update(12.0).is_some());
}
#[test]
fn constant_series_converges_to_constant() {
// Errors are all zero -> gamma 0 -> the 4-stage delay line fills with
// the constant and the filter settles on it.
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
let out = alf.batch(&[42.0_f64; 40]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
}
#[test]
fn converged_output_stays_within_price_range() {
// Once the Laguerre cascade has filled (it cold-starts from zero, so the
// first few post-warmup values ramp up toward price), the filter is a
// convex blend of recent prices and must stay inside the data range.
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let lo = prices.iter().copied().fold(f64::MAX, f64::min);
let hi = prices.iter().copied().fold(f64::MIN, f64::max);
let period = 8;
let mut alf = AdaptiveLaguerreFilter::new(period).unwrap();
for (i, v) in alf.batch(&prices).into_iter().enumerate() {
// Skip the cold-start transient (a few multiples of the window).
if i < 4 * period {
continue;
}
let v = v.expect("filter is ready well past warmup");
assert!(
v >= lo - 1e-6 && v <= hi + 1e-6,
"filter out of range at {i}"
);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1)
.collect();
let mut alf = AdaptiveLaguerreFilter::new(10).unwrap();
let got = alf.batch(&prices);
let want = naive(&prices, 10);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
alf.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(alf.is_ready());
alf.reset();
assert!(!alf.is_ready());
assert_eq!(alf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
let mut a = AdaptiveLaguerreFilter::new(7).unwrap();
let mut b = AdaptiveLaguerreFilter::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
alf.update(10.0);
alf.update(11.0);
let ready = alf.update(12.0).expect("ready after three inputs");
assert_eq!(alf.update(f64::NAN), Some(ready));
assert_eq!(alf.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,296 @@
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
/// oscillator reacts fast in a clean move and smooths through chop.
///
/// ```text
/// ER = |price_t price_{tperiod}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
/// sc = ( ER·(2/3 2/31) + 2/31 )² (KAMA smoothing constant)
/// avg_gain += sc·(gain avg_gain), avg_loss += sc·(loss avg_loss)
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
/// ```
///
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
/// efficiency ratio (`directional move / total path`) to set the smoothing each
/// bar — near `1` (a clean trend) the averages track gains and losses almost
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
/// is an RSI that is responsive when it should be and quiet when it should be. It
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
/// sets the lookback from the measured dominant cycle.
///
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
/// first value lands after `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveRsi};
///
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveRsi {
period: usize,
prices: VecDeque<f64>,
abs_changes: VecDeque<f64>,
abs_sum: f64,
prev: Option<f64>,
seed_gain: f64,
seed_loss: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl AdaptiveRsi {
/// Construct an adaptive RSI with the given efficiency-ratio `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prices: VecDeque::with_capacity(period + 1),
abs_changes: VecDeque::with_capacity(period),
abs_sum: 0.0,
prev: None,
seed_gain: 0.0,
seed_loss: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured efficiency-ratio period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
fn efficiency_ratio(&self, price: f64) -> f64 {
let oldest = *self.prices.front().expect("window non-empty");
let direction = (price - oldest).abs();
if self.abs_sum == 0.0 {
0.0
} else {
(direction / self.abs_sum).clamp(0.0, 1.0)
}
}
}
impl Indicator for AdaptiveRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let Some(prev) = self.prev else {
self.prev = Some(price);
self.prices.push_back(price);
return None;
};
let change = price - prev;
self.prev = Some(price);
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
// Maintain the price window (period + 1) and the |Δ| window (period).
self.prices.push_back(price);
if self.prices.len() > self.period + 1 {
self.prices.pop_front();
}
if self.abs_changes.len() == self.period {
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
}
self.abs_changes.push_back(change.abs());
self.abs_sum += change.abs();
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let er = self.efficiency_ratio(price);
let fast = 2.0 / 3.0;
let slow = 2.0 / 31.0;
let sc = (er * (fast - slow) + slow).powi(2);
let new_ag = ag + sc * (gain - ag);
let new_al = al + sc * (loss - al);
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gain += gain;
self.seed_loss += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let ag = self.seed_gain / self.period as f64;
let al = self.seed_loss / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prices.clear();
self.abs_changes.clear();
self.abs_sum = 0.0;
self.prev = None;
self.seed_gain = 0.0;
self.seed_loss = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let r = AdaptiveRsi::new(14).unwrap();
assert_eq!(r.period(), 14);
assert_eq!(r.warmup_period(), 15);
assert_eq!(r.name(), "AdaptiveRsi");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AdaptiveRsi::new(4).unwrap();
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn pure_uptrend_is_one_hundred() {
let mut r = AdaptiveRsi::new(5).unwrap();
let last = r
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
let mut r = AdaptiveRsi::new(4).unwrap();
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
}
#[test]
fn output_in_range() {
let mut r = AdaptiveRsi::new(14).unwrap();
for v in r
.batch(
&(0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((0.0..=100.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut r = AdaptiveRsi::new(4).unwrap();
let ready = r
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(r.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut r = AdaptiveRsi::new(4).unwrap();
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
let mut b = AdaptiveRsi::new(14).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,239 @@
//! Amihud Illiquidity — average price impact per unit traded value.
use std::collections::VecDeque;
use crate::microstructure::Trade;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Amihud Illiquidity — the average absolute log return per unit of traded
/// value over the last `period` trades (Amihud, 2002).
///
/// ```text
/// rₜ = ln(priceₜ / priceₜ₋₁)
/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume)
/// Amihud = mean of ILLIQ over the last `period` trades
/// ```
///
/// Amihud's measure captures how much the price moves for a given amount of
/// traded value: a **high** reading means small volume already shifts the price
/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
/// large volume to move the price (a deep, liquid market). It is the workhorse
/// cross-sectional liquidity proxy in market-microstructure research.
///
/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
/// (the ratio is undefined); the last value is returned and state is untouched.
/// The first valid trade only seeds the reference price.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
///
/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
/// ```
#[derive(Debug, Clone)]
pub struct AmihudIlliquidity {
period: usize,
prev_price: Option<f64>,
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
impl AmihudIlliquidity {
/// Construct a new Amihud Illiquidity over the given trade window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AmihudIlliquidity {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
// A zero-size trade has no traded value: the ratio is undefined, so the
// trade is skipped without touching the reference price.
if trade.size == 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(trade.price);
return None;
};
self.prev_price = Some(trade.price);
// `prev` and `trade.price` are both finite and strictly positive
// (enforced by `Trade::new`), so the log return is well-defined and the
// traded value is strictly positive.
let ret = (trade.price / prev).ln().abs();
let illiq = ret / (trade.price * trade.size);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(illiq);
self.sum += illiq;
if self.window.len() < self.period {
return None;
}
let value = self.sum / self.period as f64;
self.last = Some(value);
Some(value)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 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 {
"AmihudIlliquidity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(price: f64, size: f64) -> Trade {
Trade::new(price, size, Side::Buy, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AmihudIlliquidity::new(20).unwrap();
assert_eq!(a.period(), 20);
assert_eq!(a.warmup_period(), 21);
assert_eq!(a.name(), "AmihudIlliquidity");
assert!(!a.is_ready());
}
#[test]
fn known_value() {
// period 1. Seed at 100, then 101 with size 10:
// |ln(101/100)| / (101 * 10).
let mut a = AmihudIlliquidity::new(1).unwrap();
assert_eq!(a.update(trade(100.0, 10.0)), None);
let out = a.update(trade(101.0, 10.0)).unwrap();
let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
assert_relative_eq!(out, expected, epsilon = 1e-15);
}
#[test]
fn higher_for_thinner_volume() {
// Same price move on smaller volume => larger illiquidity reading.
let thin = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1.0));
a.update(trade(101.0, 1.0)).unwrap()
};
let thick = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1000.0));
a.update(trade(101.0, 1000.0)).unwrap()
};
assert!(thin > thick, "thin {thin} should exceed thick {thick}");
}
#[test]
fn flat_price_is_zero() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-15);
}
}
#[test]
fn skips_zero_size_trades() {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 10.0));
let baseline = a.update(trade(101.0, 10.0)).unwrap();
// A zero-size trade is ignored; the previous reference price is kept.
assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
// The next real trade still references price 101, not 200.
let mut control = a.clone();
let after = a.update(trade(102.0, 10.0)).unwrap();
assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
}
#[test]
fn output_is_non_negative() {
let mut a = AmihudIlliquidity::new(10).unwrap();
let trades: Vec<Trade> = (0..100)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1.0 + f64::from(i % 7),
)
})
.collect();
for v in a.batch(&trades).into_iter().flatten() {
assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for i in 0..20 {
a.update(trade(100.0 + f64::from(i), 2.0));
}
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(trade(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..80)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.25).sin() * 4.0,
1.0 + f64::from(i % 5),
)
})
.collect();
let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
let mut b = AmihudIlliquidity::new(14).unwrap();
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,353 @@
//! Andrews Pitchfork — median line and parallels off the last three swing pivots.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`AndrewsPitchfork`]: the three pitchfork lines projected to the
/// current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AndrewsPitchforkOutput {
/// The median line — from the handle pivot through the midpoint of the other two.
pub median: f64,
/// The upper parallel (through the higher of the two anchor pivots).
pub upper: f64,
/// The lower parallel (through the lower of the two anchor pivots).
pub lower: f64,
}
/// A confirmed swing pivot: its bar index and price.
#[derive(Debug, Clone, Copy)]
struct Pivot {
index: f64,
price: f64,
is_high: bool,
}
/// Andrews Pitchfork — Alan Andrews' median-line tool drawn from the three most
/// recent **swing pivots**, projected forward to the current bar.
///
/// ```text
/// detect alternating swing highs/lows with a `strength`-bar fractal
/// P0 = handle (oldest of the last three), P1, P2 = the next two
/// M = midpoint of P1 and P2
/// median(t) = P0 + slope·(t t0) slope = (M P0) / (M_t t0)
/// upper / lower = median(t) offset by the vertical gap to the higher / lower anchor
/// ```
///
/// The pitchfork projects a "fork" of three parallel lines: a central **median
/// line** drawn from a starting pivot through the midpoint of a later swing, plus
/// two parallels passing through that swing's high and low. Price tends to
/// oscillate around the median line and find support/resistance at the parallels.
/// This streaming version detects the pivots automatically with a symmetric
/// fractal of half-width `strength` (so each pivot is confirmed `strength` bars
/// late) and keeps the three most recent alternating swings.
///
/// Because it depends on swing structure, readiness is **data-dependent**: the
/// first output appears once three alternating pivots have been confirmed.
/// `warmup_period` returns the minimum bars to confirm a single pivot. Each
/// `update` is O(`strength`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AndrewsPitchfork};
///
/// let mut indicator = AndrewsPitchfork::new(2).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// // A swinging series eventually establishes a pitchfork.
/// let _ = last;
/// ```
#[derive(Debug, Clone)]
pub struct AndrewsPitchfork {
strength: usize,
window: VecDeque<Candle>,
pivots: Vec<Pivot>,
count: usize,
last: Option<AndrewsPitchforkOutput>,
}
impl AndrewsPitchfork {
/// Construct an Andrews Pitchfork with the given fractal `strength` (bars on
/// each side of a pivot).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `strength == 0`.
pub fn new(strength: usize) -> Result<Self> {
if strength == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
strength,
window: VecDeque::with_capacity(2 * strength + 1),
pivots: Vec::new(),
count: 0,
last: None,
})
}
/// Configured fractal strength.
pub const fn strength(&self) -> usize {
self.strength
}
/// Current value if available.
pub const fn value(&self) -> Option<AndrewsPitchforkOutput> {
self.last
}
/// Record a freshly confirmed pivot, keeping the last three alternating swings.
fn record_pivot(&mut self, pivot: Pivot) {
if let Some(last) = self.pivots.last_mut() {
if last.is_high == pivot.is_high {
// Same kind: keep the more extreme one (and its index).
let more_extreme = if pivot.is_high {
pivot.price > last.price
} else {
pivot.price < last.price
};
if more_extreme {
*last = pivot;
}
return;
}
}
self.pivots.push(pivot);
if self.pivots.len() > 3 {
self.pivots.remove(0);
}
}
fn project(&self, tc: f64) -> Option<AndrewsPitchforkOutput> {
let [p0, p1, p2] = self.pivots.as_slice() else {
return None;
};
let mid_t = f64::midpoint(p1.index, p2.index);
let mid_p = f64::midpoint(p1.price, p2.price);
let slope = (mid_p - p0.price) / (mid_t - p0.index);
let median = p0.price + slope * (tc - p0.index);
let off1 = p1.price - (p0.price + slope * (p1.index - p0.index));
let off2 = p2.price - (p0.price + slope * (p2.index - p0.index));
Some(AndrewsPitchforkOutput {
median,
upper: median + off1.max(off2),
lower: median + off1.min(off2),
})
}
}
impl Indicator for AndrewsPitchfork {
type Input = Candle;
type Output = AndrewsPitchforkOutput;
fn update(&mut self, candle: Candle) -> Option<AndrewsPitchforkOutput> {
self.count += 1;
let span = 2 * self.strength + 1;
if self.window.len() == span {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() == span {
let center = self.window[self.strength];
let is_high = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.high < center.high);
let is_low = self
.window
.iter()
.enumerate()
.all(|(i, c)| i == self.strength || c.low > center.low);
// Absolute index of the center bar (1-based count minus the right span).
let center_index = (self.count - 1 - self.strength) as f64;
if is_high && !is_low {
self.record_pivot(Pivot {
index: center_index,
price: center.high,
is_high: true,
});
} else if is_low && !is_high {
self.record_pivot(Pivot {
index: center_index,
price: center.low,
is_high: false,
});
}
}
let tc = (self.count - 1) as f64;
if let Some(out) = self.project(tc) {
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.window.clear();
self.pivots.clear();
self.count = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2 * self.strength + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AndrewsPitchfork"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64) -> Candle {
Candle::new_unchecked(
f64::midpoint(high, low),
high,
low,
f64::midpoint(high, low),
1_000.0,
0,
)
}
/// A clean zig-zag that prints alternating swing highs and lows.
fn zigzag() -> Vec<Candle> {
let mut out = Vec::new();
for i in 0..120 {
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
out.push(c(base + 1.0, base - 1.0));
}
out
}
#[test]
fn rejects_zero_strength() {
assert!(matches!(AndrewsPitchfork::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = AndrewsPitchfork::new(2).unwrap();
assert_eq!(p.strength(), 2);
assert_eq!(p.warmup_period(), 5);
assert_eq!(p.name(), "AndrewsPitchfork");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn none_before_three_pivots() {
let mut p = AndrewsPitchfork::new(2).unwrap();
// Too few bars to ever confirm three alternating pivots.
let out = p.batch(&[c(101.0, 99.0), c(102.0, 100.0), c(101.0, 99.0)]);
assert!(out.iter().all(Option::is_none));
}
#[test]
fn eventually_emits_on_swings() {
let mut p = AndrewsPitchfork::new(2).unwrap();
let out = p.batch(&zigzag());
assert!(
out.iter().any(Option::is_some),
"a swinging series should form a pitchfork"
);
assert!(p.is_ready());
}
#[test]
fn upper_at_or_above_lower() {
let mut p = AndrewsPitchfork::new(2).unwrap();
for o in p.batch(&zigzag()).into_iter().flatten() {
assert!(
o.upper >= o.lower,
"upper {} below lower {}",
o.upper,
o.lower
);
}
}
#[test]
fn reset_clears_state() {
let mut p = AndrewsPitchfork::new(2).unwrap();
p.batch(&zigzag());
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
assert_eq!(p.strength(), 2);
}
#[test]
fn record_pivot_keeps_more_extreme_same_kind() {
let mut p = AndrewsPitchfork::new(2).unwrap();
p.record_pivot(Pivot {
index: 0.0,
price: 100.0,
is_high: true,
});
// A higher high of the same kind replaces the stored one.
p.record_pivot(Pivot {
index: 1.0,
price: 105.0,
is_high: true,
});
assert_eq!(p.pivots.len(), 1);
assert_eq!(p.pivots[0].price, 105.0);
// A lower high of the same kind is ignored.
p.record_pivot(Pivot {
index: 2.0,
price: 102.0,
is_high: true,
});
assert_eq!(p.pivots.len(), 1);
assert_eq!(p.pivots[0].price, 105.0);
// A low pivot of the other kind is appended.
p.record_pivot(Pivot {
index: 3.0,
price: 90.0,
is_high: false,
});
assert_eq!(p.pivots.len(), 2);
// A lower low of the same kind replaces the stored low.
p.record_pivot(Pivot {
index: 4.0,
price: 85.0,
is_high: false,
});
assert_eq!(p.pivots[1].price, 85.0);
// A higher low of the same kind is ignored.
p.record_pivot(Pivot {
index: 5.0,
price: 88.0,
is_high: false,
});
assert_eq!(p.pivots[1].price, 85.0);
}
#[test]
fn batch_equals_streaming() {
let candles = zigzag();
let batch = AndrewsPitchfork::new(2).unwrap().batch(&candles);
let mut b = AndrewsPitchfork::new(2).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+163 -10
View File
@@ -28,9 +28,17 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct Atr {
period: usize,
/// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
n_minus_1: f64,
/// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
/// divides.
inv_period: f64,
prev_close: Option<f64>,
seed_buf: Vec<f64>,
avg: Option<f64>,
/// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than
/// `Option<f64>` so the hot recurrence avoids an enum-tag read per tick.
avg: f64,
seeded: bool,
}
impl Atr {
@@ -45,9 +53,12 @@ impl Atr {
}
Ok(Self {
period,
n_minus_1: (period - 1) as f64,
inv_period: 1.0 / period as f64,
prev_close: None,
seed_buf: Vec::with_capacity(period),
avg: None,
avg: 0.0,
seeded: false,
})
}
@@ -58,7 +69,72 @@ impl Atr {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.avg
if self.seeded {
Some(self.avg)
} else {
None
}
}
/// Vectorized batch over raw high/low/close columns: one `f64` per bar
/// (`NaN` during warmup). The caller guarantees the three slices are equal
/// length and finite with valid OHLC ordering (the binding validates once up
/// front); ATR only reads high, low and the previous close.
///
/// For a fresh indicator long enough to seed (`n >= period`) it runs the
/// true-range seed once and then the bare Wilder recurrence in a tight loop —
/// no per-bar `Candle` construction/validation, no `Option`, identical
/// division at the seed and `mul_add` afterwards, so the result is
/// *bit-for-bit* equal to replaying `update` over the same candles. Shorter
/// or non-fresh inputs defer to an exact `update` replay.
pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
let p = self.period;
let n = high.len();
if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p {
let mut out = vec![f64::NAN; n];
for i in 0..n {
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
if let Some(v) = self.update(candle) {
out[i] = v;
}
}
return out;
}
// Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`.
let mut out = vec![f64::NAN; p - 1];
out.reserve(n - (p - 1));
// Seed: mean of the first `period` true ranges. TR₀ has no previous close.
let mut prev_close = close[0];
let mut sum_tr = high[0] - low[0];
self.seed_buf.push(sum_tr);
for i in 1..p {
let (h, l) = (high[i], low[i]);
let tr = (h - l)
.max((h - prev_close).abs())
.max((l - prev_close).abs());
prev_close = close[i];
self.seed_buf.push(tr);
sum_tr += tr;
}
let mut avg = sum_tr / p as f64;
out.push(avg);
// Steady state: Wilder smoothing, reciprocal hoisted out of the loop.
for i in p..n {
let (h, l) = (high[i], low[i]);
let tr = (h - l)
.max((h - prev_close).abs())
.max((l - prev_close).abs());
prev_close = close[i];
avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period;
out.push(avg);
}
// Leave state where a full `update` replay would (seeded; seed_buf retained).
self.prev_close = Some(prev_close);
self.avg = avg;
self.seeded = true;
out
}
}
@@ -70,17 +146,18 @@ impl Indicator for Atr {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if let Some(avg) = self.avg {
let n = self.period as f64;
let new_avg = avg.mul_add(n - 1.0, tr) / n;
self.avg = Some(new_avg);
if self.seeded {
// Wilder smoothing with the reciprocal hoisted out of the hot path.
let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
self.avg = new_avg;
return Some(new_avg);
}
self.seed_buf.push(tr);
if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed);
self.avg = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -89,7 +166,8 @@ impl Indicator for Atr {
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf.clear();
self.avg = None;
self.avg = 0.0;
self.seeded = false;
}
fn warmup_period(&self) -> usize {
@@ -97,7 +175,7 @@ impl Indicator for Atr {
}
fn is_ready(&self) -> bool {
self.avg.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -249,6 +327,81 @@ mod tests {
}
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
let mut a = Atr::new(period).unwrap();
(0..high.len())
.map(|i| {
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
a.update(candle).unwrap_or(f64::NAN)
})
.collect()
}
/// Valid OHLC columns from a wandering base price.
fn columns(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let base: Vec<f64> = (0..n)
.map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0)
.collect();
let high = base.iter().map(|b| b + 1.0).collect();
let low = base.iter().map(|b| b - 1.0).collect();
(high, low, base)
}
#[test]
fn batch_atr_fast_path_is_bit_identical() {
let (high, low, close) = columns(300);
let mut atr = Atr::new(14).unwrap();
let got = atr.batch_atr(&high, &low, &close);
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
let mut ref_atr = Atr::new(14).unwrap();
for i in 0..high.len() {
ref_atr.update(Candle::new_unchecked(
close[i], high[i], low[i], close[i], 0.0, 0,
));
}
let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0);
assert_eq!(atr.update(next), ref_atr.update(next));
}
#[test]
fn batch_atr_falls_back_when_not_fresh() {
let (high, low, close) = columns(40);
let mut atr = Atr::new(14).unwrap();
atr.update(Candle::new_unchecked(
close[0], high[0], low[0], close[0], 0.0, 0,
));
let mut ref_atr = Atr::new(14).unwrap();
ref_atr.update(Candle::new_unchecked(
close[0], high[0], low[0], close[0], 0.0, 0,
));
let want: Vec<f64> = (0..high.len())
.map(|i| {
ref_atr
.update(Candle::new_unchecked(
close[i], high[i], low[i], close[i], 0.0, 0,
))
.unwrap_or(f64::NAN)
})
.collect();
assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want));
}
#[test]
fn batch_atr_sub_period_slice_falls_back() {
let (high, low, close) = columns(5);
let mut atr = Atr::new(14).unwrap();
let got = atr.batch_atr(&high, &low, &close);
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
assert!(got.iter().all(|x| x.is_nan()));
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
@@ -0,0 +1,279 @@
//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtrRatchetOutput {
/// The ratchet stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
/// fixed fraction of ATR **every bar**, whether or not price moves.
///
/// ```text
/// on entry (long): stop = close start_mult · ATR
/// each later bar: stop = stop + increment · ATR (ratchets toward price)
/// flip to short when close < stop, reseeding stop = close + start_mult · ATR
/// ```
///
/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
/// instead advances the stop a little each bar — `increment · ATR` — so a trade
/// that stalls is squeezed out over time even in a flat market. The initial
/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
/// `increment` controls how aggressively the leash shortens. When price closes
/// through the stop the system reverses and reseeds at the full initial distance.
///
/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AtrRatchet};
///
/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrRatchet {
atr: Atr,
atr_period: usize,
start_mult: f64,
increment: f64,
direction: f64,
stop: f64,
last: Option<AtrRatchetOutput>,
}
impl AtrRatchet {
/// Construct an ATR Ratchet stop.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
/// finite and positive.
pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
if !start_mult.is_finite()
|| start_mult <= 0.0
|| !increment.is_finite()
|| increment <= 0.0
{
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
atr_period,
start_mult,
increment,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(atr_period, start_mult, increment)`.
pub const fn params(&self) -> (usize, f64, f64) {
(self.atr_period, self.start_mult, self.increment)
}
/// Current value if available.
pub const fn value(&self) -> Option<AtrRatchetOutput> {
self.last
}
}
impl Indicator for AtrRatchet {
type Input = Candle;
type Output = AtrRatchetOutput;
fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
let atr = self.atr.update(candle)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
} else if self.direction > 0.0 {
self.stop += self.increment * atr;
if close < self.stop {
self.direction = -1.0;
self.stop = close + self.start_mult * atr;
}
} else {
self.stop -= self.increment * atr;
if close > self.stop {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
}
}
let out = AtrRatchetOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.atr.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AtrRatchet"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
AtrRatchet::new(0, 4.0, 0.1),
Err(Error::PeriodZero)
));
assert!(matches!(
AtrRatchet::new(14, 0.0, 0.1),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
assert_eq!(r.params(), (14, 4.0, 0.1));
assert_eq!(r.warmup_period(), 14);
assert_eq!(r.name(), "AtrRatchet");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = r.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn stall_eventually_triggers_flip() {
// A long trend then a long flat stretch: the ratchet creeps up each bar
// and eventually overtakes the flat close, flipping to short.
let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
let mut candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
// Flat stretch at the last price.
candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
let dirs: Vec<f64> = r
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(
dirs.iter().any(|&d| d < 0.0),
"the ratchet should eventually flip short"
);
}
#[test]
fn reset_clears_state() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
r.batch(&candles);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,176 @@
//! Auto-Fibonacci — retracement of the most significant recent swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// How many recent pivots to consider when picking the dominant leg.
const PIVOT_HISTORY: usize = 6;
/// The seven canonical retracement ratios, in ascending order.
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
/// Auto-Fibonacci retracement levels for the dominant recent swing leg.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AutoFibOutput {
/// 0.0% — the dominant leg's end.
pub level_0: f64,
/// 23.6% retracement.
pub level_236: f64,
/// 38.2% retracement.
pub level_382: f64,
/// 50% retracement.
pub level_500: f64,
/// 61.8% retracement.
pub level_618: f64,
/// 78.6% retracement.
pub level_786: f64,
/// 100% — the dominant leg's start.
pub level_1000: f64,
}
/// Auto-Fibonacci (`AutoFib`).
///
/// Like [`crate::indicators::FibRetracement`], but instead of always using the
/// immediate last leg it scans the last six confirmed pivots and anchors the
/// retracement on the single largest-magnitude leg among them — the dominant
/// swing the market is most likely respecting.
///
/// Parameter-free; construction is infallible. Returns `None` until two pivots
/// have confirmed.
///
/// See `crates/wickra-core/src/indicators/auto_fib.rs`.
#[derive(Debug, Clone)]
pub struct AutoFib {
swing: SwingTracker,
}
impl AutoFib {
/// Construct a new Auto-Fibonacci tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
}
}
fn levels(&self) -> Option<AutoFibOutput> {
let dominant = self.swing.pivots().windows(2).max_by(|x, y| {
(x[0].price - x[1].price)
.abs()
.total_cmp(&(y[0].price - y[1].price).abs())
})?;
let (start, end) = (dominant[0].price, dominant[1].price);
let level = |r: f64| end + r * (start - end);
Some(AutoFibOutput {
level_0: level(RATIOS[0]),
level_236: level(RATIOS[1]),
level_382: level(RATIOS[2]),
level_500: level(RATIOS[3]),
level_618: level(RATIOS[4]),
level_786: level(RATIOS[5]),
level_1000: level(RATIOS[6]),
})
}
}
impl Default for AutoFib {
fn default() -> Self {
Self::new()
}
}
impl Indicator for AutoFib {
type Input = Candle;
type Output = AutoFibOutput;
fn update(&mut self, candle: Candle) -> Option<AutoFibOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"AutoFib"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = AutoFib::new();
assert_eq!(indicator.name(), "AutoFib");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!AutoFib::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = AutoFib::new();
let outputs: Vec<_> = candles_for_pivots(&[120.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
}
#[test]
fn anchors_on_the_largest_leg() {
// Pivots: 130 -> 120 (small, 10) -> 220 (large, 100) -> 200 (small, 20).
// The dominant leg is 120 -> 220; its retracement spans [120, 220].
let mut indicator = AutoFib::new();
let mut last = None;
for candle in candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// Largest leg 120 -> 220: 0% on 220 (end), 100% on 120 (start).
assert_relative_eq!(v.level_0, 220.0);
assert_relative_eq!(v.level_1000, 120.0);
assert_relative_eq!(v.level_500, 170.0);
assert_relative_eq!(v.level_618, 220.0 + 0.618 * (120.0 - 220.0));
}
#[test]
fn reset_clears_state() {
let mut indicator = AutoFib::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]);
let mut a = AutoFib::new();
let mut b = AutoFib::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,340 @@
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use std::f64::consts::TAU;
use crate::error::{Error, Result};
use crate::indicators::roofing_filter::RoofingFilter;
use crate::traits::Indicator;
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
const AVG_LENGTH: usize = 3;
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
/// period** of the market by correlating a roofing-filtered price with lagged
/// copies of itself and reading off the spectral peak.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
///
/// ```text
/// Filt = RoofingFilter(price) (detrend + denoise)
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
/// for each candidate period:
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t1} (EMA across time)
/// normalise by a decaying max, then
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
/// ```
///
/// The autocorrelation function emphasises whatever cycle is actually present and
/// suppresses noise; transforming it into a periodogram and taking the
/// power-weighted centre of gravity gives a smooth, robust estimate of the
/// dominant cycle length. That cycle is the key input for every *adaptive*
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
/// output is a period in bars within `[min_period, max_period]`.
///
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
/// O(`max_period²`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
/// use std::f64::consts::TAU;
///
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AutocorrelationPeriodogram {
min_period: usize,
max_period: usize,
roof: RoofingFilter,
buffer: VecDeque<f64>,
r: Vec<f64>,
max_pwr: f64,
last: Option<f64>,
}
impl AutocorrelationPeriodogram {
/// Construct an autocorrelation periodogram searching cycles in
/// `[min_period, max_period]`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is `0`, or
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
/// `max_period <= min_period`.
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
if min_period == 0 || max_period == 0 {
return Err(Error::PeriodZero);
}
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
return Err(Error::InvalidPeriod {
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
});
}
Ok(Self {
min_period,
max_period,
roof: RoofingFilter::new(10, max_period)?,
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
r: vec![0.0; max_period + 1],
max_pwr: 0.0,
last: None,
})
}
/// Configured `(min_period, max_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.min_period, self.max_period)
}
/// Current dominant-cycle estimate if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
fn correlation(&self, lag: usize) -> f64 {
let len = self.buffer.len();
let filt = |k: usize| self.buffer[len - 1 - k];
let m = AVG_LENGTH as f64;
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
for count in 0..AVG_LENGTH {
let x = filt(count);
let y = filt(lag + count);
sx += x;
sy += y;
sxx += x * x;
syy += y * y;
sxy += x * y;
}
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
if denom > 0.0 {
(m * sxy - sx * sy) / denom.sqrt()
} else {
0.0
}
}
}
impl Indicator for AutocorrelationPeriodogram {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let filt = self.roof.update(price)?;
if self.buffer.len() == self.max_period + AVG_LENGTH {
self.buffer.pop_front();
}
self.buffer.push_back(filt);
if self.buffer.len() < self.max_period + AVG_LENGTH {
return None;
}
// Autocorrelation across lags.
let mut corr = vec![0.0; self.max_period + 1];
for (lag, c) in corr.iter_mut().enumerate() {
*c = self.correlation(lag);
}
// Periodogram: spectral power for each candidate period, EMA'd over time.
self.max_pwr *= 0.995;
for period in self.min_period..=self.max_period {
let mut cosine = 0.0;
let mut sine = 0.0;
for (n, &cn) in corr
.iter()
.enumerate()
.take(self.max_period + 1)
.skip(AVG_LENGTH)
{
let angle = TAU * n as f64 / period as f64;
cosine += cn * angle.cos();
sine += cn * angle.sin();
}
let power = cosine * cosine + sine * sine;
self.r[period] = 0.2 * power + 0.8 * self.r[period];
if self.r[period] > self.max_pwr {
self.max_pwr = self.r[period];
}
}
// Power-weighted centre of gravity of the strong periods.
let mut spx = 0.0;
let mut sp = 0.0;
for period in self.min_period..=self.max_period {
let pwr = if self.max_pwr > 0.0 {
self.r[period] / self.max_pwr
} else {
0.0
};
if pwr >= 0.5 {
spx += period as f64 * pwr;
sp += pwr;
}
}
let dominant = if sp > 0.0 {
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
} else {
self.min_period as f64
};
self.last = Some(dominant);
Some(dominant)
}
fn reset(&mut self) {
self.roof.reset();
self.buffer.clear();
self.r.iter_mut().for_each(|x| *x = 0.0);
self.max_pwr = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.max_period + AVG_LENGTH
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AutocorrelationPeriodogram"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
AutocorrelationPeriodogram::new(0, 48),
Err(Error::PeriodZero)
));
assert!(matches!(
AutocorrelationPeriodogram::new(3, 48),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
AutocorrelationPeriodogram::new(48, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
assert_eq!(p.periods(), (10, 48));
assert_eq!(p.warmup_period(), 51);
assert_eq!(p.name(), "AutocorrelationPeriodogram");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
let xs: Vec<f64> = (0..40)
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
.collect();
let out = p.batch(&xs);
let warmup = p.warmup_period(); // 23
assert_eq!(warmup, 23);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn output_within_period_band() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
for v in p.batch(&xs).into_iter().flatten() {
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
}
}
#[test]
fn detects_injected_cycle() {
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
let xs: Vec<f64> = (0..600)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
assert!(
(last - 20.0).abs() < 6.0,
"expected ~20-bar cycle, got {last}"
);
}
#[test]
fn ignores_non_finite() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
p.batch(
&(0..80)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect::<Vec<_>>(),
);
let before = p.value();
assert_eq!(p.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
p.batch(
&(0..120)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect::<Vec<_>>(),
);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..200)
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_input_falls_back_to_min_period() {
// Constant input has zero variance, so every lag correlation is
// degenerate (denom <= 0), the max power is zero and no period clears
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
let flat = [100.0_f64; 200];
let last = AutocorrelationPeriodogram::new(10, 48)
.unwrap()
.batch(&flat)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 10.0);
}
}
@@ -0,0 +1,231 @@
//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
//! completed calendar-day sessions.
use std::collections::VecDeque;
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Daily Range over the last `period` completed sessions.
///
/// The indicator tracks the running high / low of the current session (the
/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`). When a new day begins, the just-finished session's
/// range (`high - low`) joins a rolling window of the last `period` completed
/// days, and the reported value is their mean. The current, still-forming day is
/// excluded until it closes. No value is produced until the first session
/// completes.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AverageDailyRange};
///
/// let hour = 3_600_000;
/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
/// // First bar of day 2 closes day 1: ADR = 10.
/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((v - 10.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct AverageDailyRange {
period: usize,
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
cur_high: f64,
cur_low: f64,
completed: VecDeque<f64>,
sum: f64,
}
impl AverageDailyRange {
/// Construct an ADR indicator over `period` completed days.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
utc_offset_minutes,
day_key: None,
cur_high: f64::NEG_INFINITY,
cur_low: f64::INFINITY,
completed: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured `(period, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.period, self.utc_offset_minutes)
}
/// Most recent ADR if at least one session has completed.
pub fn value(&self) -> Option<f64> {
if self.completed.is_empty() {
None
} else {
Some(self.sum / self.completed.len() as f64)
}
}
}
impl Indicator for AverageDailyRange {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
match self.day_key {
Some(prev) if prev == key => {
if candle.high > self.cur_high {
self.cur_high = candle.high;
}
if candle.low < self.cur_low {
self.cur_low = candle.low;
}
}
Some(_) => {
let range = self.cur_high - self.cur_low;
self.completed.push_back(range);
self.sum += range;
if self.completed.len() > self.period {
self.sum -= self
.completed
.pop_front()
.expect("len > period implies a front element");
}
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
None => {
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
}
self.value()
}
fn reset(&mut self) {
self.day_key = None;
self.cur_high = f64::NEG_INFINITY;
self.cur_low = f64::INFINITY;
self.completed.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
!self.completed.is_empty()
}
fn name(&self) -> &'static str {
"AverageDailyRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
const DAY: i64 = 24 * HOUR;
fn c(high: f64, low: f64, ts: i64) -> Candle {
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AverageDailyRange::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let adr = AverageDailyRange::new(5, -60).unwrap();
assert_eq!(adr.params(), (5, -60));
assert_eq!(adr.name(), "AverageDailyRange");
assert_eq!(adr.warmup_period(), 5);
assert!(!adr.is_ready());
assert!(adr.value().is_none());
}
#[test]
fn averages_completed_day_ranges() {
let mut adr = AverageDailyRange::new(3, 0).unwrap();
// Day 1: range 10.
assert!(adr.update(c(110.0, 100.0, 0)).is_none());
assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
// Day 2 opens -> day 1 (range 10) completes.
let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
assert_relative_eq!(v, 10.0);
assert!(adr.is_ready());
// Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 10.0);
}
#[test]
fn rolls_off_oldest_day_beyond_period() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0)); // day 1 range 10
let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
assert_relative_eq!(v, 10.0);
// Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 12.5);
// Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
assert_relative_eq!(v, 17.5);
}
#[test]
fn reset_clears_state() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0));
adr.update(c(120.0, 110.0, DAY));
adr.reset();
assert!(!adr.is_ready());
assert!(adr.value().is_none());
assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
c(
110.0 + f64::from(i % 5),
100.0 - f64::from(i % 3),
i64::from(i) * 6 * HOUR,
)
})
.collect();
let mut a = AverageDailyRange::new(4, 0).unwrap();
let mut b = AverageDailyRange::new(4, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,243 @@
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
/// around a target `period` and rejects both the trend (low frequencies) and the
/// noise (high frequencies).
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
///
/// ```text
/// beta = cos(2π / period)
/// gamma = 1 / cos(4π · bandwidth / period)
/// alpha = gamma sqrt(gamma² 1)
/// BP_t = 0.5·(1 alpha)·(price_t price_{t2})
/// + beta·(1 + alpha)·BP_{t1} alpha·BP_{t2}
/// ```
///
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
/// and cycle-amplitude work.
///
/// The recursion needs two prior prices and two prior outputs; until then it emits
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
/// produced every bar. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, BandpassFilter};
///
/// let mut indicator = BandpassFilter::new(20, 0.3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BandpassFilter {
period: usize,
bandwidth: f64,
beta: f64,
alpha: f64,
prev_price_1: Option<f64>,
prev_price_2: Option<f64>,
bp1: f64,
bp2: f64,
last: Option<f64>,
}
impl BandpassFilter {
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
/// fraction.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
/// `(0, 1)`.
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
return Err(Error::InvalidParameter {
message: "bandpass bandwidth must be in (0, 1)",
});
}
let period_f = period as f64;
let beta = (2.0 * PI / period_f).cos();
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
Ok(Self {
period,
bandwidth,
beta,
alpha,
prev_price_1: None,
prev_price_2: None,
bp1: 0.0,
bp2: 0.0,
last: None,
})
}
/// Configured `(period, bandwidth)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.bandwidth)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BandpassFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let bp = match self.prev_price_2 {
Some(p2) => {
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
- self.alpha * self.bp2
}
None => 0.0,
};
self.prev_price_2 = self.prev_price_1;
self.prev_price_1 = Some(price);
self.bp2 = self.bp1;
self.bp1 = bp;
self.last = Some(bp);
Some(bp)
}
fn reset(&mut self) {
self.prev_price_1 = None;
self.prev_price_2 = None;
self.bp1 = 0.0;
self.bp2 = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BandpassFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
BandpassFilter::new(0, 0.3),
Err(Error::PeriodZero)
));
assert!(matches!(
BandpassFilter::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BandpassFilter::new(20, 1.0),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bp = BandpassFilter::new(20, 0.3).unwrap();
assert_eq!(bp.params(), (20, 0.3));
assert_eq!(bp.warmup_period(), 1);
assert_eq!(bp.name(), "BandpassFilter");
assert!(!bp.is_ready());
assert_eq!(bp.value(), None);
}
#[test]
fn first_bars_are_zero() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
assert_eq!(bp.update(100.0), Some(0.0));
assert_eq!(bp.update(101.0), Some(0.0));
// From the third bar the recursion is active.
assert!(bp.is_ready());
}
#[test]
fn constant_input_stays_zero() {
// A trend-free flat input has no cyclic content -> output stays 0.
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn cyclic_input_oscillates_around_zero() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
.collect();
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
let mean = out.iter().sum::<f64>() / out.len() as f64;
assert!(
mean.abs() < 1.0,
"bandpass output should be ~zero mean, got {mean}"
);
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
let before = bp.value();
assert_eq!(bp.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
assert!(bp.is_ready());
bp.reset();
assert!(!bp.is_ready());
assert_eq!(bp.update(100.0), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
let mut b = BandpassFilter::new(20, 0.3).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Bat harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep
/// `0.886` D completion:
///
/// ```text
/// AB / XA ∈ [0.382, 0.50]
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.618, 2.618]
/// AD / XA ∈ [0.84, 0.93] (≈ 0.886 — the defining D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`.
#[derive(Debug, Clone)]
pub struct Bat {
swing: SwingTracker,
has_emitted: bool,
}
impl Bat {
/// Construct a new Bat detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Bat {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Bat {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.50),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.618, 2.618),
(ad / xa, 0.84, 0.93),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Bat"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Bat::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Bat::new();
assert_eq!(indicator.name(), "Bat");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Bat::default().is_ready());
}
#[test]
fn bullish_bat_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_bat_is_minus_one() {
let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Bat::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
let mut a = Bat::new();
let mut b = Bat::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,254 @@
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
/// oscillator: how much volume (effort) a bar spent relative to the price range
/// (result) it achieved, both normalised against their own recent averages.
///
/// ```text
/// range_t = high_t low_t
/// rel_vol = volume_t / SMA(volume, period)
/// rel_range = range_t / SMA(range, period)
/// BetterVol = rel_vol rel_range
/// ```
///
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
/// through the relationship between **effort** (volume) and **result** (the bar's
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
/// produced little movement, the hallmark of absorption (supply meeting demand at
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
///
/// Both legs are normalised by their `period` simple moving averages (including
/// the current bar), so the output is centred near `0` and self-scales to the
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BetterVolume};
///
/// let mut indicator = BetterVolume::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BetterVolume {
period: usize,
volumes: VecDeque<f64>,
ranges: VecDeque<f64>,
vol_sum: f64,
range_sum: f64,
last: Option<f64>,
}
impl BetterVolume {
/// Construct a new Better Volume oscillator with the given averaging `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
volumes: VecDeque::with_capacity(period),
ranges: VecDeque::with_capacity(period),
vol_sum: 0.0,
range_sum: 0.0,
last: None,
})
}
/// Configured averaging period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BetterVolume {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
if self.volumes.len() == self.period {
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
self.range_sum -= self.ranges.pop_front().expect("non-empty");
}
self.volumes.push_back(candle.volume);
self.ranges.push_back(range);
self.vol_sum += candle.volume;
self.range_sum += range;
if self.volumes.len() < self.period {
return None;
}
let n = self.period as f64;
let sma_vol = self.vol_sum / n;
let sma_range = self.range_sum / n;
let rel_vol = if sma_vol > 0.0 {
candle.volume / sma_vol
} else {
0.0
};
let rel_range = if sma_range > 0.0 {
range / sma_range
} else {
0.0
};
let out = rel_vol - rel_range;
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.volumes.clear();
self.ranges.clear();
self.vol_sum = 0.0;
self.range_sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BetterVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, high, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bv = BetterVolume::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 20);
assert_eq!(bv.name(), "BetterVolume");
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let out = bv.batch(&candles);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn steady_bars_are_neutral() {
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
let mut bv = BetterVolume::new(4).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn churn_bar_is_positive() {
// Three normal bars, then a high-volume narrow-range bar -> positive.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "churn bar should be positive, got {last}");
}
#[test]
fn ease_of_movement_bar_is_negative() {
// Three normal bars, then a wide-range light-volume bar -> negative.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < 0.0,
"ease-of-movement bar should be negative, got {last}"
);
}
#[test]
fn zero_everything_is_zero() {
// Zero volume and zero range -> both legs guarded to 0.
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
for v in bv.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut bv = BetterVolume::new(3).unwrap();
bv.batch(
&(0..6)
.map(|_| candle(102.0, 100.0, 1_000.0))
.collect::<Vec<_>>(),
);
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(
base + 2.0,
base - 1.5,
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
)
})
.collect();
let batch = BetterVolume::new(20).unwrap().batch(&candles);
let mut b = BetterVolume::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,281 @@
//! Realized Bipower Variation — a jump-robust quadratic-variation estimator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Realized Bipower Variation — the sum of *adjacent* absolute log-return
/// products over the trailing `period` returns, scaled to estimate integrated
/// variance.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// BV = (π / 2) · Σ |r_t| · |r_{t1}| over the window
/// ```
///
/// Bipower variation (Barndorff-Nielsen & Shephard 2004) estimates the same
/// integrated variance as [`RealizedVolatility`](crate::RealizedVolatility)'s
/// `Σ r²`, but by multiplying *neighbouring* absolute returns rather than
/// squaring a single one. A price jump inflates exactly one return; because that
/// return appears in a product with its (ordinary) neighbour rather than squared,
/// its contribution stays bounded — so `BV` is **robust to jumps** while realized
/// variance is not. The constant `π / 2 = μ₁⁻²` (with `μ₁ = E|Z| = √(2/π)` for a
/// standard normal) debiases the product of two half-normal magnitudes back to a
/// variance scale.
///
/// The output is on the **variance** scale (the jump-robust counterpart of
/// realized *variance*, not volatility); take its square root for a volatility,
/// and compare `RV BV` to isolate the jump contribution. A window of `period`
/// returns contributes `period 1` adjacent products; each `update` is O(1) via
/// a running sum.
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{BipowerVariation, Indicator};
///
/// let mut indicator = BipowerVariation::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BipowerVariation {
period: usize,
prev_price: Option<f64>,
/// Rolling window of the last `period` log returns.
window: VecDeque<f64>,
/// Running sum of adjacent absolute-return products inside the window.
sum_adjacent: f64,
last: Option<f64>,
}
impl BipowerVariation {
/// Construct a new bipower-variation indicator.
///
/// `period` is the number of log returns in the rolling window; the estimate
/// uses the `period 1` adjacent products between them.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `period == 1` (an adjacent product needs at
/// least two returns).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "bipower variation period must be >= 2",
});
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum_adjacent: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
/// `μ₁⁻² = π / 2`, the debiasing constant for a product of half-normal returns.
const MU1_INV_SQ: f64 = std::f64::consts::FRAC_PI_2;
impl Indicator for BipowerVariation {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
// The incoming return forms a product with the current last return.
if let Some(&back) = self.window.back() {
self.sum_adjacent += back.abs() * r.abs();
}
self.window.push_back(r);
if self.window.len() > self.period {
let first = self.window.pop_front().expect("window is non-empty");
// The product between the dropped return and the new front leaves.
let second = *self.window.front().expect("window still has >= 1 element");
self.sum_adjacent -= first.abs() * second.abs();
}
if self.window.len() < self.period {
return None;
}
// Products are non-negative; the rolling subtraction can leave a tiny
// negative residual when returns are ~0, so clamp before scaling.
let bv = MU1_INV_SQ * self.sum_adjacent.max(0.0);
self.last = Some(bv);
Some(bv)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum_adjacent = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price, then the window fills.
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BipowerVariation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BipowerVariation::new(0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_period_one() {
assert!(matches!(
BipowerVariation::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bv = BipowerVariation::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 21);
assert_eq!(bv.name(), "BipowerVariation");
assert!(!bv.is_ready());
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BipowerVariation::new(5).unwrap();
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn known_value() {
// period = 2: one adjacent product. r1 = ln(1.1), r2 = ln(0.9).
// BV = (π/2)·|r1|·|r2|.
let mut bv = BipowerVariation::new(2).unwrap();
let out = bv.batch(&[100.0, 110.0, 99.0]);
assert!(out[1].is_none());
let r1 = (110.0_f64 / 100.0).ln();
let r2 = (99.0_f64 / 110.0).ln();
let expected = std::f64::consts::FRAC_PI_2 * r1.abs() * r2.abs();
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn rolling_window_drops_oldest_product() {
// period = 2, four prices -> two emissions, each a single product.
let mut bv = BipowerVariation::new(2).unwrap();
let out = bv.batch(&[100.0, 110.0, 99.0, 105.0]);
let r2 = (99.0_f64 / 110.0).ln();
let r3 = (105.0_f64 / 99.0).ln();
let expected = std::f64::consts::FRAC_PI_2 * r2.abs() * r3.abs();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut bv = BipowerVariation::new(10).unwrap();
for v in bv.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut bv = BipowerVariation::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in bv.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "bipower variation must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut bv = BipowerVariation::new(5).unwrap();
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(bv.update(f64::NAN), last);
assert_eq!(bv.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut bv = BipowerVariation::new(5).unwrap();
let warmup = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(bv.update(-5.0), Some(baseline));
assert_eq!(bv.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = bv.clone();
let after = bv.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut bv = BipowerVariation::new(5).unwrap();
bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = BipowerVariation::new(20).unwrap().batch(&prices);
let mut b = BipowerVariation::new(20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,193 @@
//! Body Size Percent — candle body as a fraction of its range.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Body Size Percent — the absolute body as a fraction of the bar's range.
///
/// ```text
/// BodySizePct = |close open| / (high low)
/// ```
///
/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened
/// at one extreme and closed at the other, no wicks), `0` a doji (open equals
/// close, the bar is all wick). It is the *unsigned* magnitude companion to
/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction,
/// this keeps only the conviction, which is exactly what candlestick body /
/// range filters key on. A zero-range bar carries no information and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BodySizePct};
///
/// let mut indicator = BodySizePct::new();
/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5.
/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct BodySizePct {
has_emitted: bool,
}
impl BodySizePct {
/// Construct a new Body Size Percent transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BodySizePct {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let out = if range == 0.0 {
// A zero-range bar has no body proportion to speak of.
0.0
} else {
(candle.close - candle.open).abs() / range
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BodySizePct"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// |12 - 10| / (14 - 10) = 0.5.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn marubozu_is_one() {
// open == low, close == high, no wicks -> full body -> 1.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn doji_is_zero() {
// open == close with a real range -> body 0.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn unsigned_regardless_of_direction() {
// A red bar with the same body magnitude reads identically to a green one.
let mut bsp = BodySizePct::new();
let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap();
let mut bsp2 = BodySizePct::new();
let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap();
assert_relative_eq!(green, red, epsilon = 1e-12);
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
})
.collect();
let mut bsp = BodySizePct::new();
for v in bsp.batch(&candles).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]");
}
}
#[test]
fn name_metadata() {
let bsp = BodySizePct::new();
assert_eq!(bsp.name(), "BodySizePct");
}
#[test]
fn emits_from_first_candle() {
let mut bsp = BodySizePct::new();
assert_eq!(bsp.warmup_period(), 1);
assert!(!bsp.is_ready());
assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bsp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bsp = BodySizePct::new();
bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bsp.is_ready());
bsp.reset();
assert!(!bsp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = BodySizePct::new();
let mut b = BodySizePct::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+183 -14
View File
@@ -1,7 +1,5 @@
//! Bollinger Bands.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
@@ -49,7 +47,13 @@ pub struct BollingerOutput {
pub struct BollingerBands {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
/// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
/// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path.
buf: Box<[f64]>,
/// Index of the next slot to write — also the oldest element once full.
head: usize,
/// Number of slots filled, saturating at `period`.
count: usize,
sum: f64,
sum_sq: f64,
/// Number of finite updates since the running sums were last reseeded
@@ -80,7 +84,9 @@ impl BollingerBands {
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
buf: vec![0.0; period].into_boxed_slice(),
head: 0,
count: 0,
sum: 0.0,
sum_sq: 0.0,
updates_since_recompute: 0,
@@ -102,8 +108,84 @@ impl BollingerBands {
self.multiplier
}
/// Vectorized flat batch for bindings: returns `n * 4` values laid out as
/// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`.
///
/// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq`
/// and drift-reseed, writing the four band values directly instead of an
/// `Option<BollingerOutput>` per element. Same add/subtract order, same reseed
/// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to
/// replaying `update`, including the long-stream drift bound. Any other state,
/// or a non-finite element, defers to the exact `update` replay.
///
/// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch),
/// which returns `Vec<Option<BollingerOutput>>`; only the bindings, which want
/// a flat `f64` buffer, call this.
pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
let p = self.period;
let n = inputs.len();
if self.count != 0
|| self.updates_since_recompute != 0
|| !inputs.iter().all(|x| x.is_finite())
{
// Slow path: exact replay of `update` into the flat layout.
let mut out = vec![f64::NAN; n * 4];
for (i, &x) in inputs.iter().enumerate() {
if let Some(o) = self.update(x) {
out[i * 4] = o.upper;
out[i * 4 + 1] = o.middle;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.stddev;
}
}
return out;
}
let p_f64 = p as f64;
let mult = self.multiplier;
// Pre-sized output: warmup rows stay NaN, ready rows are written in place
// by index — no per-row `push` length/capacity check.
let mut out = vec![f64::NAN; n * 4];
for (i, &x) in inputs.iter().enumerate() {
if self.count == p {
let old = self.buf[self.head];
self.sum -= old;
self.sum_sq -= old * old;
self.buf[self.head] = x;
self.sum += x;
self.sum_sq += x * x;
} else {
self.buf[self.head] = x;
self.sum += x;
self.sum_sq += x * x;
self.count += 1;
}
self.head += 1;
if self.head == p {
self.head = 0;
}
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
self.sum = chronological.clone().copied().sum();
self.sum_sq = chronological.map(|&v| v * v).sum();
self.updates_since_recompute = 0;
}
if self.count == p {
let mean = self.sum / p_f64;
let stddev = (self.sum_sq / p_f64 - mean * mean).max(0.0).sqrt();
let band = mult * stddev;
out[i * 4] = mean + band;
out[i * 4 + 1] = mean;
out[i * 4 + 2] = mean - band;
out[i * 4 + 3] = stddev;
}
}
out
}
fn current(&self) -> Option<BollingerOutput> {
if self.window.len() != self.period {
if self.count != self.period {
return None;
}
let n = self.period as f64;
@@ -129,25 +211,38 @@ impl Indicator for BollingerBands {
if !input.is_finite() {
return self.current();
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
if self.count == self.period {
let old = self.buf[self.head];
self.sum -= old;
self.sum_sq -= old * old;
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
} else {
self.buf[self.head] = input;
self.sum += input;
self.sum_sq += input * input;
self.count += 1;
}
self.head += 1;
if self.head == self.period {
self.head = 0;
}
self.window.push_back(input);
self.sum += input;
self.sum_sq += input * input;
self.updates_since_recompute += 1;
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
self.sum = self.window.iter().copied().sum();
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
// Reseed in chronological order (oldest at `head`) to keep the running
// sums bit-equivalent to a fresh from-scratch pass on stable inputs.
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
self.sum = chronological.clone().copied().sum();
self.sum_sq = chronological.map(|&x| x * x).sum();
self.updates_since_recompute = 0;
}
self.current()
}
fn reset(&mut self) {
self.window.clear();
self.head = 0;
self.count = 0;
self.sum = 0.0;
self.sum_sq = 0.0;
self.updates_since_recompute = 0;
@@ -158,7 +253,7 @@ impl Indicator for BollingerBands {
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
self.count == self.period
}
fn name(&self) -> &'static str {
@@ -171,6 +266,7 @@ mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
use std::collections::VecDeque;
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
assert!(
@@ -332,6 +428,79 @@ mod tests {
);
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
/// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
let mut bb = BollingerBands::new(period, mult).unwrap();
let mut out = Vec::with_capacity(series.len() * 4);
for &x in series {
match bb.update(x) {
Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
None => out.extend_from_slice(&[f64::NAN; 4]),
}
}
out
}
#[test]
fn batch_bands_fast_path_is_bit_identical_with_reseed() {
// > 16*period inputs so the drift-reseed branch fires inside batch_bands.
let series: Vec<f64> = (0..500)
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
.collect();
let mut bb = BollingerBands::new(20, 2.0).unwrap();
let got = bb.batch_bands(&series);
assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
// State continues identically.
let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
for &x in &series {
ref_bb.update(x);
}
assert_eq!(bb.update(55.0), ref_bb.update(55.0));
}
#[test]
fn batch_bands_falls_back_on_non_finite() {
let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
let mut bb = BollingerBands::new(3, 2.0).unwrap();
assert!(bits_eq(
&bb.batch_bands(&series),
&bb_replay(3, 2.0, &series)
));
}
#[test]
fn batch_bands_falls_back_when_not_fresh() {
let mut bb = BollingerBands::new(3, 2.0).unwrap();
bb.update(99.0);
let series = [1.0, 2.0, 3.0, 4.0];
let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
ref_bb.update(99.0);
let mut want = Vec::new();
for &x in &series {
match ref_bb.update(x) {
Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
None => want.extend_from_slice(&[f64::NAN; 4]),
}
}
assert!(bits_eq(&bb.batch_bands(&series), &want));
}
#[test]
fn batch_bands_sub_period_slice_is_all_nan() {
let series = [1.0, 2.0, 3.0];
let mut bb = BollingerBands::new(10, 2.0).unwrap();
let got = bb.batch_bands(&series);
assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
}
#[test]
fn ignores_non_finite_input() {
let mut bb = BollingerBands::new(5, 2.0).unwrap();
@@ -0,0 +1,256 @@
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
//! recent price.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Bomar Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BomarBandsOutput {
/// Upper band: `middle + |middle| · p`.
pub upper: f64,
/// Middle line: the simple moving average over the window.
pub middle: f64,
/// Lower band: `middle |middle| · p`.
pub lower: f64,
}
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
/// fraction of recent closes falls inside them.
///
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
/// inspiration — percentage bands around a moving average, with the percentage
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
/// realises that idea deterministically: the half-width is the `coverage`
/// quantile of the relative deviations from the midline, so by construction
/// `coverage` of the window's closes lie inside the bands.
///
/// ```text
/// middle = SMA(close, period)
/// dev_i = | close_i / middle 1 | // relative distance from midline
/// p = coverage-quantile of { dev_i } // type-7 interpolation
/// upper = middle + |middle| · p
/// lower = middle |middle| · p
/// ```
///
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
/// here is data-driven: the bands widen in turbulent regimes and tighten in
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
/// an order statistic of the actual deviations rather than a multiple of the
/// standard deviation, so it is unaffected by the shape of the tails beyond the
/// `coverage` rank. When the midline is zero the relative deviation is
/// undefined and the bands collapse onto the midline.
///
/// # Example
///
/// ```
/// use wickra_core::{BomarBands, Indicator};
///
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 7));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BomarBands {
period: usize,
coverage: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl BomarBands {
/// Construct new Bomar Bands.
///
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
/// `(0.0, 1.0]`.
pub fn new(period: usize, coverage: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
return Err(Error::InvalidParameter {
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
});
}
Ok(Self {
period,
coverage,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured coverage fraction.
pub const fn coverage(&self) -> f64 {
self.coverage
}
}
impl Indicator for BomarBands {
type Input = f64;
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let sum: f64 = self.window.iter().sum();
let middle = sum / (self.period as f64);
let denom = middle.abs();
self.scratch.clear();
for &v in &self.window {
let dev = if denom == 0.0 {
0.0
} else {
((v - middle) / denom).abs()
};
self.scratch.push(dev);
}
self.scratch.sort_by(f64::total_cmp);
let p = quantile_sorted(&self.scratch, self.coverage);
let offset = denom * p;
Some(BomarBandsOutput {
upper: middle + offset,
middle,
lower: middle - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BomarBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
assert!(BomarBands::new(1, 0.85).is_ok());
}
#[test]
fn rejects_out_of_range_coverage() {
assert!(matches!(
BomarBands::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, -0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bb = BomarBands::new(20, 0.85).unwrap();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BomarBands");
assert!(!bb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
assert!(bb.update(100.0).is_none());
assert!(bb.update(102.0).is_none());
assert!(bb.update(98.0).is_none());
assert!(bb.update(104.0).is_some());
assert!(bb.is_ready());
}
#[test]
fn known_bands() {
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn zero_midline_collapses_bands() {
// Window mean exactly zero -> relative deviation undefined -> collapse.
let mut bb = BomarBands::new(2, 0.85).unwrap();
let out = bb.batch(&[3.0, -3.0]);
let last = out[1].unwrap();
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_bands` window.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
for v in [100.0, 102.0, 98.0, 104.0] {
bb.update(v);
}
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
assert!(bb.update(100.0).is_none());
}
}
@@ -0,0 +1,154 @@
//! Butterfly harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Butterfly — a 5-point (X-A-B-C-D) harmonic pattern with a `0.786` B and an
/// **extended** D that overshoots X:
///
/// ```text
/// AB / XA ∈ [0.74, 0.84] (≈ 0.786)
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.618, 2.618]
/// AD / XA ∈ [1.27, 1.618] (the defining extended D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/butterfly.rs`.
#[derive(Debug, Clone)]
pub struct Butterfly {
swing: SwingTracker,
has_emitted: bool,
}
impl Butterfly {
/// Construct a new Butterfly detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Butterfly {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Butterfly {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.74, 0.84),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.618, 2.618),
(ad / xa, 1.27, 1.618),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Butterfly"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Butterfly::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Butterfly::new();
assert_eq!(indicator.name(), "Butterfly");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Butterfly::default().is_ready());
}
#[test]
fn bullish_butterfly_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_butterfly_is_minus_one() {
let out = run(&[150.0, 110.0, 141.4, 121.4, 170.2]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Butterfly::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
let mut a = Butterfly::new();
let mut b = Butterfly::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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,157 @@
//! Close vs Open — the signed relative body of a bar.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Close vs Open — the bar's body as a signed fraction of its open price.
///
/// ```text
/// CloseVsOpen = (close open) / open
/// ```
///
/// A scale-free, signed measure of how far price travelled from open to close:
/// `+0.02` is a bar that closed 2% above its open (a green bar), `0.02` the
/// mirror. Unlike [`BalanceOfPower`](crate::BalanceOfPower) — which normalises
/// the body by the bar *range* — this normalises by the *open price*, so it is
/// directly comparable to a return and stays meaningful across instruments of
/// different nominal price. A zero open carries no scale and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, CloseVsOpen};
///
/// let mut indicator = CloseVsOpen::new();
/// // open 100, close 102 -> +0.02.
/// let c = Candle::new(100.0, 103.0, 99.0, 102.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.02).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CloseVsOpen {
has_emitted: bool,
}
impl CloseVsOpen {
/// Construct a new Close vs Open transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for CloseVsOpen {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let out = if candle.open == 0.0 {
// A zero open price carries no scale to normalise against.
0.0
} else {
(candle.close - candle.open) / candle.open
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CloseVsOpen"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (102 - 100) / 100 = 0.02.
let mut cvo = CloseVsOpen::new();
assert_relative_eq!(
cvo.update(candle(100.0, 103.0, 99.0, 102.0, 0)).unwrap(),
0.02,
epsilon = 1e-12
);
}
#[test]
fn negative_body_is_negative() {
let mut cvo = CloseVsOpen::new();
// close below open -> negative.
assert_relative_eq!(
cvo.update(candle(100.0, 101.0, 97.0, 98.0, 0)).unwrap(),
-0.02,
epsilon = 1e-12
);
}
#[test]
fn zero_open_yields_zero() {
// Candle permits a zero open (only finiteness + OHLC ordering checked).
let mut cvo = CloseVsOpen::new();
assert_relative_eq!(
cvo.update(candle(0.0, 1.0, 0.0, 0.5, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn name_metadata() {
let cvo = CloseVsOpen::new();
assert_eq!(cvo.name(), "CloseVsOpen");
}
#[test]
fn emits_from_first_candle() {
let mut cvo = CloseVsOpen::new();
assert_eq!(cvo.warmup_period(), 1);
assert!(!cvo.is_ready());
assert!(cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(cvo.is_ready());
}
#[test]
fn reset_clears_state() {
let mut cvo = CloseVsOpen::new();
cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(cvo.is_ready());
cvo.reset();
assert!(!cvo.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = CloseVsOpen::new();
let mut b = CloseVsOpen::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,258 @@
//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation
/// coefficient between price and a perfectly straight ramp over the lookback.
///
/// ```text
/// CTI = corr( price over the window , [0, 1, …, period1] )
/// ```
///
/// John Ehlers' CTI asks "how closely does recent price track a straight line?"
/// by correlating the windowed price against the time index itself. A reading near
/// `+1` means price is rising in a near-perfect line (strong uptrend); near `1`
/// means a clean downtrend; near `0` means no linear trend (a range or choppy
/// market). Because correlation is scale- and offset-invariant, the slope's
/// steepness does not matter — only how *linear* the move is — which makes CTI an
/// unusually clean trend/range classifier. It differs from
/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a
/// *lagged copy of itself* rather than with time.
///
/// The output is in `[1, +1]`; a flat window (zero price variance) returns `0`.
/// The first value lands after `period` inputs; each `update` recomputes the
/// correlation over the window in O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CorrelationTrendIndicator};
///
/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct CorrelationTrendIndicator {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl CorrelationTrendIndicator {
/// Construct a CTI over `period` bars.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two
/// points).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "CTI needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn compute(&self) -> f64 {
let n = self.period as f64;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
let mut sum_xt = 0.0;
for (i, &x) in self.window.iter().enumerate() {
let t = i as f64;
sum_x += x;
sum_xx += x * x;
sum_xt += x * t;
}
// Time index 0..n-1 has closed-form sums.
let sum_t = n * (n - 1.0) / 2.0;
let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
let cov = n * sum_xt - sum_x * sum_t;
let var_x = n * sum_xx - sum_x * sum_x;
let var_t = n * sum_tt - sum_t * sum_t;
let denom = (var_x * var_t).sqrt();
if denom == 0.0 {
0.0
} else {
(cov / denom).clamp(-1.0, 1.0)
}
}
}
impl Indicator for CorrelationTrendIndicator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"CorrelationTrendIndicator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(matches!(
CorrelationTrendIndicator::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(CorrelationTrendIndicator::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let cti = CorrelationTrendIndicator::new(20).unwrap();
assert_eq!(cti.period(), 20);
assert_eq!(cti.warmup_period(), 20);
assert_eq!(cti.name(), "CorrelationTrendIndicator");
assert!(!cti.is_ready());
assert_eq!(cti.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn clean_uptrend_is_one() {
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
let last = cti
.batch(&(0..40).map(f64::from).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn clean_downtrend_is_minus_one() {
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
let last = cti
.batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_window_is_zero() {
let mut cti = CorrelationTrendIndicator::new(8).unwrap();
let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut cti = CorrelationTrendIndicator::new(20).unwrap();
for v in cti
.batch(
&(0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn ignores_non_finite() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
let ready = cti
.batch(&[1.0, 2.0, 3.0, 4.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(cti.update(f64::NAN), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
cti.batch(&[1.0, 2.0, 3.0, 4.0]);
assert!(cti.is_ready());
cti.reset();
assert!(!cti.is_ready());
assert_eq!(cti.value(), None);
assert_eq!(cti.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs);
let mut b = CorrelationTrendIndicator::new(20).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Crab harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Crab — a 5-point (X-A-B-C-D) harmonic pattern with the deepest D completion
/// of the family, an `1.618` extension of XA:
///
/// ```text
/// AB / XA ∈ [0.382, 0.618]
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [2.24, 3.618] (a very long terminal leg)
/// AD / XA ∈ [1.55, 1.65] (≈ 1.618 — the defining D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/crab.rs`.
#[derive(Debug, Clone)]
pub struct Crab {
swing: SwingTracker,
has_emitted: bool,
}
impl Crab {
/// Construct a new Crab detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Crab {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Crab {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.618),
(bc / ab, 0.382, 0.886),
(cd / bc, 2.24, 3.618),
(ad / xa, 1.55, 1.65),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Crab"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Crab::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Crab::new();
assert_eq!(indicator.name(), "Crab");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Crab::default().is_ready());
}
#[test]
fn bullish_crab_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_crab_is_minus_one() {
let out = run(&[150.0, 110.0, 130.0, 112.5, 174.7]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Crab::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]);
let mut a = Crab::new();
let mut b = Crab::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,176 @@
//! Cup-and-Handle (and Inverse) continuation chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Cup-and-Handle / Inverse — a rounded base (the cup) followed by a shallow
/// pullback (the handle) near the rim, then a breakout in the cup's direction.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%) and read from the
/// last four pivots:
///
/// ```text
/// cup-and-handle (bullish, +1): Rim(high) , Cup(low) , Rim(high) , Handle(low)
/// the two rims match (±3%) ; the handle low sits ABOVE the cup low (a shallow
/// pullback) and below the right rim
///
/// inverse (bearish, -1): Rim(low) , Cap(high) , Rim(low) , Handle(high)
/// the two rims match ; the handle high sits BELOW the cap high and above the
/// right rim
/// ```
///
/// The shallow handle (closer to the rim than the cup extreme) is what
/// distinguishes a cup-and-handle from a plain double bottom/top. Output is
/// `+1.0` / `-1.0` / `0.0`; never `None`.
#[derive(Debug, Clone)]
pub struct CupAndHandle {
swing: SwingTracker,
has_emitted: bool,
}
impl CupAndHandle {
/// Construct a new Cup-and-Handle detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 4),
has_emitted: false,
}
}
}
impl Default for CupAndHandle {
fn default() -> Self {
Self::new()
}
}
impl Indicator for CupAndHandle {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 4 {
return Some(0.0);
}
let n = pivots.len();
let rim_left = pivots[n - 4];
let extreme = pivots[n - 3];
let rim_right = pivots[n - 2];
let handle = pivots[n - 1];
let rims_match = approx_equal(rim_left.price, rim_right.price, LEVEL_TOLERANCE);
if handle.direction < 0.0 {
// Bullish cup-and-handle: rims are highs, cup is the low between them,
// handle is a shallow low above the cup but below the right rim.
if rims_match && handle.price > extreme.price && handle.price < rim_right.price {
return Some(1.0);
}
} else if rims_match && handle.price < extreme.price && handle.price > rim_right.price {
// Inverse: rims are lows, cap is the high, handle a shallow high.
return Some(-1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CupAndHandle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = CupAndHandle::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = CupAndHandle::new();
assert_eq!(indicator.name(), "CupAndHandle");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!CupAndHandle::default().is_ready());
}
#[test]
fn cup_and_handle_is_plus_one() {
// Rims 120/121, cup 90 (deep), handle 110 (shallow, above the cup).
let out = run(&[120.0, 90.0, 121.0, 110.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn inverse_cup_and_handle_is_minus_one() {
// Lead high then rims 100/101, cap 130, handle 110 (below cap, above rim).
let out = run(&[140.0, 100.0, 130.0, 101.0, 110.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn deep_handle_is_not_cup_and_handle() {
// Handle (85) below the cup low (90) → a double bottom, not cup-and-handle.
let out = run(&[120.0, 90.0, 121.0, 85.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn inverse_with_mismatched_rims_does_not_trigger() {
// Inverse shape (ends high) but the rims (100 / 90) diverge → enters the
// inverse branch yet reports no pattern.
let out = run(&[140.0, 100.0, 130.0, 90.0, 110.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = CupAndHandle::new();
for c in candles_for_pivots(&[120.0, 90.0, 121.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[120.0, 90.0, 121.0, 110.0]);
let mut a = CupAndHandle::new();
let mut b = CupAndHandle::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Cypher harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Cypher — a 5-point (X-A-B-C-D) harmonic pattern whose C leg is measured
/// against XA (not AB) and whose D retraces the XC leg by `0.786`:
///
/// ```text
/// AB / XA ∈ [0.382, 0.618]
/// BC / XA ∈ [1.13, 1.414] (C extends beyond A, measured on XA)
/// CD / XC ∈ [0.74, 0.83] (≈ 0.786 retracement of XC — the D completion)
/// ```
///
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/cypher.rs`.
#[derive(Debug, Clone)]
pub struct Cypher {
swing: SwingTracker,
has_emitted: bool,
}
impl Cypher {
/// Construct a new Cypher detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Cypher {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Cypher {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let xc = (p.c - p.x).abs();
let cd = (p.d - p.c).abs();
let matched = ratios_in(&[
(ab / xa, 0.382, 0.618),
(bc / xa, 1.13, 1.414),
(cd / xc, 0.74, 0.83),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Cypher"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Cypher::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Cypher::new();
assert_eq!(indicator.name(), "Cypher");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Cypher::default().is_ready());
}
#[test]
fn bullish_cypher_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_cypher_is_minus_one() {
let out = run(&[150.0, 110.0, 130.0, 82.0, 135.45]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Cypher::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
let mut a = Cypher::new();
let mut b = Cypher::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,202 @@
//! Day-of-Week Profile — the mean bar return for each weekday.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
const DAYS: usize = 7;
/// Day-of-Week Profile output: the per-weekday mean return.
///
/// `bins[i]` is the mean simple return of all bars whose local weekday was `i`,
/// with Monday as `0` through Sunday as `6`. Weekdays with no bars read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct DayOfWeekProfileOutput {
/// Per-weekday mean return, Monday first. Always length 7.
pub bins: Vec<f64>,
}
/// Mean bar return bucketed by local weekday (Monday `0` .. Sunday `6`).
///
/// Each bar's simple return `close / previous_close - 1` is accumulated into the
/// bucket of its local weekday (the wall-clock day of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`), and the
/// profile reports the running mean per weekday. The first bar produces no output.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, DayOfWeekProfile};
///
/// let day = 24 * 3_600_000;
/// let mut prof = DayOfWeekProfile::new(0);
/// // 1970-01-01 was a Thursday (weekday 3).
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, day).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 7);
/// ```
#[derive(Debug, Clone)]
pub struct DayOfWeekProfile {
utc_offset_minutes: i32,
prev_close: Option<f64>,
sum: [f64; DAYS],
count: [u64; DAYS],
last: Option<DayOfWeekProfileOutput>,
}
impl DayOfWeekProfile {
/// Construct a Day-of-Week Profile with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
prev_close: None,
sum: [0.0; DAYS],
count: [0; DAYS],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&DayOfWeekProfileOutput> {
self.last.as_ref()
}
fn snapshot(&self) -> DayOfWeekProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
DayOfWeekProfileOutput { bins }
}
}
impl Indicator for DayOfWeekProfile {
type Input = Candle;
type Output = DayOfWeekProfileOutput;
fn update(&mut self, candle: Candle) -> Option<DayOfWeekProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let day = civil.weekday as usize;
self.sum[day] += ret;
self.count[day] += 1;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
} else {
None
};
self.prev_close = Some(candle.close);
result
}
fn reset(&mut self) {
self.prev_close = None;
self.sum = [0.0; DAYS];
self.count = [0; DAYS];
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"DayOfWeekProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const DAY: i64 = 24 * 3_600_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let prof = DayOfWeekProfile::new(60);
assert_eq!(prof.utc_offset_minutes(), 60);
assert_eq!(prof.name(), "DayOfWeekProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn buckets_by_weekday() {
let mut prof = DayOfWeekProfile::new(0);
// 1970-01-01 Thursday (3); 01-02 Friday (4).
assert!(prof.update(c(100.0, 0)).is_none());
let out = prof.update(c(101.0, DAY)).unwrap(); // Friday return +0.01
assert_eq!(out.bins.len(), 7);
assert_relative_eq!(out.bins[4], 0.01); // Friday
assert_relative_eq!(out.bins[3], 0.0); // Thursday had no return
assert!(prof.is_ready());
}
#[test]
fn averages_same_weekday_across_weeks() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(100.0, 0)); // Thu
prof.update(c(101.0, DAY)); // Fri +0.01
// Jump to next Friday (7 days later from day 0 -> +7 days, weekday 4).
prof.update(c(100.0, 7 * DAY)); // Thu+? actually day 7 -> weekday (7+3)%7=3 Thu
let out = prof.update(c(103.0, 8 * DAY)).unwrap(); // day 8 -> Fri, return
// Friday now has two samples; both positive.
assert!(out.bins[4] > 0.0);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, DAY)).unwrap();
assert_relative_eq!(out.bins[4], 0.0); // Friday, guarded return 0
}
#[test]
fn reset_clears_state() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(100.0, 0));
prof.update(c(101.0, DAY));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| c(100.0 + f64::from(i % 5), i64::from(i) * DAY))
.collect();
let mut a = DayOfWeekProfile::new(0);
let mut b = DayOfWeekProfile::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,218 @@
//! Derivative Oscillator (Constance Brown).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::rsi::Rsi;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
///
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
/// histogram:
///
/// ```text
/// rsi = RSI(price, rsi_period)
/// s1 = EMA(rsi, smooth1)
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
/// signal = SMA(s2, signal_period)
/// DerivativeOscillator = s2 - signal
/// ```
///
/// The double EMA smoothing strips the RSI's high-frequency noise, and
/// subtracting the SMA signal removes the residual level, so the result
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
///
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period 2`
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativeOscillator, Indicator};
///
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DerivativeOscillator {
rsi: Rsi,
ema1: Ema,
ema2: Ema,
signal: Sma,
warmup: usize,
}
impl DerivativeOscillator {
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
/// SMA signal periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is `0`.
pub fn new(
rsi_period: usize,
smooth1: usize,
smooth2: usize,
signal_period: usize,
) -> Result<Self> {
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
rsi: Rsi::new(rsi_period)?,
ema1: Ema::new(smooth1)?,
ema2: Ema::new(smooth2)?,
signal: Sma::new(signal_period)?,
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
})
}
/// Total warmup length (also returned by `warmup_period`).
pub const fn warmup(&self) -> usize {
self.warmup
}
}
impl Indicator for DerivativeOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let s1 = self.ema1.update(rsi)?;
let s2 = self.ema2.update(s1)?;
let signal = self.signal.update(s2)?;
Some(s2 - signal)
}
fn reset(&mut self) {
self.rsi.reset();
self.ema1.reset();
self.ema2.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
self.warmup
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"DerivativeOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(matches!(
DerivativeOscillator::new(0, 5, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 0, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 3, 0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
// 14 + 5 + 3 + 9 - 2 = 29.
assert_eq!(d.warmup(), 29);
assert_eq!(d.warmup_period(), 29);
assert_eq!(d.name(), "DerivativeOscillator");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let out = d.batch(&prices);
let warmup = d.warmup_period();
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first value must land at warmup_period - 1"
);
}
#[test]
fn matches_manual_chain() {
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut rsi = Rsi::new(14).unwrap();
let mut e1 = Ema::new(5).unwrap();
let mut e2 = Ema::new(3).unwrap();
let mut sig = Sma::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = d.update(p);
let want = rsi
.update(p)
.and_then(|r| e1.update(r))
.and_then(|x| e2.update(x))
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,169 @@
//! Disparity Index.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Disparity Index — the percentage gap between price and its moving average.
///
/// ```text
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
/// ```
///
/// Originating in Japanese technical analysis (*kairi*), the disparity index
/// expresses how far price has stretched from its `period`-bar simple moving
/// average, as a percentage of that average. Positive readings mean price is
/// above the mean (potentially overbought / strong), negative readings mean it
/// is below (potentially oversold / weak); the magnitude measures how
/// over-extended the move is.
///
/// The first output lands once the inner SMA is ready (input `period`). If the
/// moving average is exactly zero the gap percentage is undefined and the index
/// returns `0.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{DisparityIndex, Indicator};
///
/// let mut indicator = DisparityIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DisparityIndex {
period: usize,
sma: Sma,
}
impl DisparityIndex {
/// Construct a disparity index over `period` inputs.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DisparityIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let mean = self.sma.update(input)?;
if mean == 0.0 {
return Some(0.0);
}
Some(100.0 * (input - mean) / mean)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"DisparityIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(DisparityIndex::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let di = DisparityIndex::new(14).unwrap();
assert_eq!(di.period(), 14);
assert_eq!(di.warmup_period(), 14);
assert_eq!(di.name(), "DisparityIndex");
}
#[test]
fn warmup_then_known_value() {
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
let mut di = DisparityIndex::new(3).unwrap();
assert_eq!(di.update(2.0), None);
assert_eq!(di.update(4.0), None);
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn constant_series_is_zero() {
// Price equals its own mean -> zero disparity.
let mut di = DisparityIndex::new(5).unwrap();
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn negative_when_below_mean() {
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
let mut di = DisparityIndex::new(3).unwrap();
let v = di.batch(&[10.0, 8.0, 6.0]);
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
}
#[test]
fn zero_mean_returns_zero() {
// A window summing to zero (mean 0) makes the percentage undefined; the
// index returns 0.0 rather than a non-finite value.
let mut di = DisparityIndex::new(2).unwrap();
assert_eq!(di.update(-3.0), None);
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut di = DisparityIndex::new(5).unwrap();
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = DisparityIndex::new(7).unwrap();
let mut b = DisparityIndex::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,188 @@
//! Double Top / Double Bottom reversal chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern.
///
/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold
/// zig-zag, [`SWING_THRESHOLD`] = 5%). A pattern is recognised on the bar that
/// confirms the **second** matching extreme:
///
/// ```text
/// double top : … High₁ , Low , High₂ with High₁ ≈ High₂ → -1 (bearish)
/// double bottom : … Low₁ , High , Low₂ with Low₁ ≈ Low₂ → +1 (bullish)
/// ```
///
/// Two extremes count as the same level when they are within
/// [`LEVEL_TOLERANCE`] (3%) of each other. Because pivots strictly alternate
/// high/low, the trough between the twin tops (or the peak between the twin
/// bottoms) is guaranteed to sit beyond both, so no extra separation check is
/// needed.
///
/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on
/// every other bar (including warmup and bars that confirm a pivot which does
/// not complete the pattern). Like the candlestick family this detector never
/// returns `None`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, DoubleTopBottom, Indicator};
///
/// let mut indicator = DoubleTopBottom::new();
/// for (i, &(high, low)) in [
/// (100.0, 99.5),
/// (120.0, 119.5),
/// (110.0, 100.0), // confirms the first top at 120
/// (120.0, 119.0), // confirms the trough at 100
/// (115.0, 110.0), // confirms the second top at 120 → double top
/// ]
/// .iter()
/// .enumerate()
/// {
/// let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap();
/// let signal = indicator.update(c).unwrap();
/// if i == 4 {
/// assert_eq!(signal, -1.0);
/// }
/// }
/// ```
#[derive(Debug, Clone)]
pub struct DoubleTopBottom {
swing: SwingTracker,
has_emitted: bool,
}
impl DoubleTopBottom {
/// Construct a new Double Top / Double Bottom detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
has_emitted: false,
}
}
}
impl Default for DoubleTopBottom {
fn default() -> Self {
Self::new()
}
}
impl Indicator for DoubleTopBottom {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 3 {
return Some(0.0);
}
let first = pivots[pivots.len() - 3];
let last = pivots[pivots.len() - 1];
if approx_equal(first.price, last.price, LEVEL_TOLERANCE) {
// `last` is the just-confirmed extreme: a high → double top (bearish),
// a low → double bottom (bullish).
return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// The first complete pattern needs three confirmed pivots; the earliest
// bar that can confirm a third pivot is the fifth.
5
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"DoubleTopBottom"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = DoubleTopBottom::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = DoubleTopBottom::new();
assert_eq!(indicator.name(), "DoubleTopBottom");
assert_eq!(indicator.warmup_period(), 5);
assert!(!indicator.is_ready());
assert!(!DoubleTopBottom::default().is_ready());
}
#[test]
fn double_top_is_minus_one() {
// Twin highs 120 / 120 with a 100 trough → double top on the second.
let out = run(&[120.0, 100.0, 120.0]);
assert_eq!(*out.last().unwrap(), -1.0);
// All earlier bars are warmup / non-completing.
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn double_bottom_is_plus_one() {
// Lead high, then twin lows 100 / 99 around a 120 peak → double bottom.
let out = run(&[130.0, 100.0, 120.0, 99.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn unequal_tops_do_not_trigger() {
// Second top 140 diverges from the first (120) → no pattern.
let out = run(&[120.0, 100.0, 140.0]);
assert_eq!(*out.last().unwrap(), 0.0);
assert!(out.iter().all(|&x| x == 0.0));
}
#[test]
fn reset_clears_state() {
let mut indicator = DoubleTopBottom::new();
for c in candles_for_pivots(&[120.0, 100.0, 120.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[120.0, 100.0, 120.0]);
let mut a = DoubleTopBottom::new();
let mut b = DoubleTopBottom::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,301 @@
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::indicators::std_dev::StdDev;
use crate::traits::Indicator;
// Chande's definitional constants.
const STD_PERIOD: usize = 5; // volatility window
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
const MIN_PERIOD: usize = 5; // fastest RSI lookback
const MAX_PERIOD: usize = 30; // slowest RSI lookback
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
/// volatile markets and lengthens in calm ones.
///
/// A standard RSI uses a fixed period; the DMI varies it from the recent
/// volatility so the oscillator stays responsive when the market is fast and
/// smooth when it is quiet:
///
/// ```text
/// vol = StdDev(close, 5)
/// vol_avg = SMA(vol, 10)
/// Vi = vol / vol_avg (volatility index)
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
/// avg_gain, avg_loss = simple means of the last `td` price changes
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
/// lengthens it toward `30` (slower). The averages of gains and losses are
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
/// returns the neutral `50`.
///
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
/// buffer always holds enough history for any dynamic lookback up to `30`.
///
/// # Example
///
/// ```
/// use wickra_core::{DynamicMomentumIndex, Indicator};
///
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DynamicMomentumIndex {
period: usize,
vol: StdDev,
vol_avg: Sma,
prev_close: Option<f64>,
/// The last `MAX_PERIOD` price changes, oldest at the front.
changes: VecDeque<f64>,
last_vol_avg: Option<f64>,
last_value: Option<f64>,
}
impl DynamicMomentumIndex {
/// Construct a DMI with the given base RSI period (Chande uses 14).
///
/// # 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: StdDev::new(STD_PERIOD)?,
vol_avg: Sma::new(STD_AVG_PERIOD)?,
prev_close: None,
changes: VecDeque::with_capacity(MAX_PERIOD),
last_vol_avg: None,
last_value: None,
})
}
/// Configured base period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
if vol_avg <= 0.0 || vol <= 0.0 {
// No measurable volatility -> slowest (calmest) lookback.
return MAX_PERIOD;
}
let vi = vol / vol_avg;
let td = (self.period as f64 / vi).round();
// td is finite and positive here; clamp into the valid band.
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
}
}
impl Indicator for DynamicMomentumIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// Track the smoothed volatility on every close.
if let Some(v) = self.vol.update(input) {
self.last_vol_avg = self.vol_avg.update(v);
}
// Record the price change.
if let Some(prev) = self.prev_close {
let change = input - prev;
if self.changes.len() == MAX_PERIOD {
self.changes.pop_front();
}
self.changes.push_back(change);
}
self.prev_close = Some(input);
let vol = self.vol.value()?;
let vol_avg = self.last_vol_avg?;
if self.changes.len() < MAX_PERIOD {
return None;
}
let td = self.dynamic_period(vol, vol_avg);
// Average gains and losses over the last `td` changes.
let mut sum_gain = 0.0;
let mut sum_loss = 0.0;
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
if c > 0.0 {
sum_gain += c;
} else if c < 0.0 {
sum_loss -= c;
}
}
let denom = sum_gain + sum_loss;
let v = if denom == 0.0 {
50.0
} else {
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
100.0 * (sum_gain / denom)
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.vol.reset();
self.vol_avg.reset();
self.prev_close = None;
self.changes.clear();
self.last_vol_avg = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
MAX_PERIOD + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DynamicMomentumIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
DynamicMomentumIndex::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessors `period` + `value` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(dmi.period(), 14);
assert_eq!(dmi.value(), None);
assert_eq!(dmi.warmup_period(), 31);
assert_eq!(dmi.name(), "DynamicMomentumIndex");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..50)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let out = dmi.batch(&prices);
for (i, v) in out.iter().enumerate().take(30) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
}
#[test]
fn pure_uptrend_is_one_hundred() {
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
// Constant prices: no volatility (dynamic period -> max) and no changes
// -> neutral 50.
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_stays_in_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
for v in dmi.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
}
}
#[test]
fn high_volatility_shortens_period() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
// Zero volatility -> slowest lookback.
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
}
#[test]
fn ignores_non_finite_input() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let ready = dmi
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(dmi.update(f64::NAN), Some(ready));
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(dmi.is_ready());
dmi.reset();
assert!(!dmi.is_ready());
assert_eq!(dmi.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DynamicMomentumIndex::new(14).unwrap();
let mut b = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Exponential Hull Moving Average (EHMA).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Exponential Hull Moving Average: the Hull construction built from EMAs
/// instead of WMAs.
///
/// ```text
/// EHMA = EMA( 2 · EMA(price, period/2) EMA(price, period), round(sqrt(period)) )
/// ```
///
/// Alan Hull's [`Hma`](crate::Hma) uses weighted moving averages; replacing them
/// with exponential moving averages keeps the same lag-reduction trick — a fast
/// half-length average minus a full-length one, smoothed over `sqrt(period)` —
/// while inheriting the EMA's strictly recursive O(1) update and infinite
/// (exponentially decaying) memory. The result is marginally smoother than the
/// WMA-based Hull at the cost of a little more lag.
///
/// The half period is `(period / 2).max(1)` and the smoothing period is
/// `round(sqrt(period)).max(1)`, matching the rounding used by [`Hma`].
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Ehma};
///
/// let mut indicator = Ehma::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ehma {
period: usize,
half_ema: Ema,
full_ema: Ema,
smooth_ema: Ema,
}
impl Ehma {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let half = (period / 2).max(1);
let smooth = (period as f64).sqrt().round() as usize;
let smooth = smooth.max(1);
Ok(Self {
period,
half_ema: Ema::new(half)?,
full_ema: Ema::new(period)?,
smooth_ema: Ema::new(smooth)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Ehma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Feed both component EMAs on every input so they warm up in parallel;
// gating the longer one behind the shorter would delay the first
// emission past `warmup_period()`.
let h = self.half_ema.update(input);
let f = self.full_ema.update(input);
let (h, f) = (h?, f?);
let diff = 2.0 * h - f;
self.smooth_ema.update(diff)
}
fn reset(&mut self) {
self.half_ema.reset();
self.full_ema.reset();
self.smooth_ema.reset();
}
fn warmup_period(&self) -> usize {
// full_ema seeds at `period`, then smooth_ema needs another
// (round(sqrt(period)) - 1) values to seed.
let sm = (self.period as f64).sqrt().round() as usize;
self.period + sm.max(1) - 1
}
fn is_ready(&self) -> bool {
self.smooth_ema.is_ready()
}
fn name(&self) -> &'static str {
"EHMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_ehma() {
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&[10.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
let mut a = Ehma::new(9).unwrap();
let mut b = Ehma::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ehma = Ehma::new(9).unwrap();
ehma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(ehma.is_ready());
ehma.reset();
assert!(!ehma.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Ehma::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `name`.
/// `warmup_period` is covered by `first_emission_matches_warmup_period`.
#[test]
fn accessors_and_metadata() {
let ehma = Ehma::new(9).unwrap();
assert_eq!(ehma.period(), 9);
assert_eq!(ehma.name(), "EHMA");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&prices);
let warmup = ehma.warmup_period();
// full EMA seeds at 9, smooth EMA round(sqrt(9))=3 needs 2 more -> 11.
assert_eq!(warmup, 11);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first EHMA value must land at warmup_period - 1"
);
}
#[test]
fn matches_independent_emas() {
// The two component EMAs run as independent siblings on the price
// stream; EHMA must equal feeding three standalone EMAs and combining.
let prices: Vec<f64> = (1..=50)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut ehma = Ehma::new(9).unwrap();
let mut half = Ema::new(4).unwrap(); // (9 / 2).max(1)
let mut full = Ema::new(9).unwrap();
let mut smooth = Ema::new(3).unwrap(); // round(sqrt(9))
for (i, &p) in prices.iter().enumerate() {
let got = ehma.update(p);
let want = match (half.update(p), full.update(p)) {
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
_ => None,
};
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn period_one_collapses_to_pass_through() {
// period 1: half=1, full=1, smooth=round(sqrt(1))=1; every EMA seeds on
// the first input, so EHMA(1) passes the price straight through.
let mut ehma = Ehma::new(1).unwrap();
assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12);
assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12);
}
}
@@ -0,0 +1,192 @@
//! Elder Ray — Bull Power and Bear Power.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// One Elder Ray reading: the bull and bear power for a bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderRayOutput {
/// `high EMA(close)`: how far buyers pushed price above the trend mean.
pub bull_power: f64,
/// `low EMA(close)`: how far sellers pushed price below the trend mean
/// (negative in a normal market).
pub bear_power: f64,
}
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
///
/// An EMA of the close marks the market's consensus of value; the bar's high and
/// low relative to it measure how far the bulls and bears could push price away
/// from that consensus:
///
/// ```text
/// ema = EMA(close, period)
/// BullPower = high - ema
/// BearPower = low - ema
/// ```
///
/// Bull Power is normally positive (the high prints above the mean) and Bear
/// Power normally negative (the low prints below it). Their behaviour relative
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
///
/// The first reading lands once the inner EMA is seeded, at bar `period`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ElderRay, Indicator};
///
/// let mut er = ElderRay::new(13).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 + 0.5, 1.0, i64::from(i)).unwrap();
/// last = er.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderRay {
period: usize,
ema: Ema,
}
impl ElderRay {
/// Construct an Elder Ray with the given EMA period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
ema: Ema::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ElderRay {
type Input = Candle;
type Output = ElderRayOutput;
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
let ema = self.ema.update(candle.close)?;
Some(ElderRayOutput {
bull_power: candle.high - ema,
bear_power: candle.low - ema,
})
}
fn reset(&mut self) {
self.ema.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"ElderRay"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new(close, high, low, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(ElderRay::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let er = ElderRay::new(13).unwrap();
assert_eq!(er.period(), 13);
assert_eq!(er.warmup_period(), 13);
assert_eq!(er.name(), "ElderRay");
}
#[test]
fn warmup_then_known_value() {
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
let mut er = ElderRay::new(3).unwrap();
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
}
#[test]
fn matches_manual_ema() {
let bars: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
candle(base + 2.0, base - 2.0, base)
})
.collect();
let mut er = ElderRay::new(13).unwrap();
let mut ema = Ema::new(13).unwrap();
for (i, c) in bars.iter().enumerate() {
let got = er.update(*c);
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(g), Some((b, be))) = (got, want) {
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut er = ElderRay::new(5).unwrap();
er.batch(
&(0..20)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(er.is_ready());
er.reset();
assert!(!er.is_ready());
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..30)
.map(|i| {
let base = 50.0 + f64::from(i);
candle(base + 1.5, base - 1.5, base)
})
.collect();
let mut a = ElderRay::new(7).unwrap();
let mut b = ElderRay::new(7).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,360 @@
//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`ElderSafeZone`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderSafeZoneOutput {
/// The `SafeZone` stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the
/// **average market noise** away from price.
///
/// ```text
/// long market noise = average downside penetration = mean( prev_low low | low < prev_low )
/// short market noise = average upside penetration = mean( high prev_high | high > prev_high )
/// long stop = ratchet_up( low_t coeff · avg_down_penetration )
/// short stop = ratchet_down( high_t + coeff · avg_up_penetration )
/// ```
///
/// Elder defines *noise* in an uptrend as the part of each bar that pokes below
/// the previous bar's low (a "downside penetration"). Averaging those
/// penetrations over a lookback and placing the stop `coeff` multiples below the
/// current low keeps the stop just outside normal pullbacks while still exiting on
/// a genuine reversal. The stop trails in the trend's favour and flips when price
/// closes through it. The average uses only the bars that actually penetrated
/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's
/// extreme.
///
/// The first bar seeds the prior candle; the next `period` bars accumulate the
/// penetration statistics, so the first stop lands after `period + 1` inputs.
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ElderSafeZone};
///
/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderSafeZone {
period: usize,
coeff: f64,
prev: Option<Candle>,
down_pen: VecDeque<f64>,
up_pen: VecDeque<f64>,
down_sum: f64,
up_sum: f64,
down_count: usize,
up_count: usize,
direction: f64,
stop: f64,
last: Option<ElderSafeZoneOutput>,
}
impl ElderSafeZone {
/// Construct an Elder `SafeZone` stop with the given averaging `period` and
/// noise `coeff`icient.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive.
pub fn new(period: usize, coeff: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coeff.is_finite() || coeff <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
coeff,
prev: None,
down_pen: VecDeque::with_capacity(period),
up_pen: VecDeque::with_capacity(period),
down_sum: 0.0,
up_sum: 0.0,
down_count: 0,
up_count: 0,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(period, coeff)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.coeff)
}
/// Current value if available.
pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
self.last
}
fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
if window.len() == period {
let old = window.pop_front().expect("non-empty");
*sum -= old;
if old > 0.0 {
*count -= 1;
}
}
window.push_back(pen);
*sum += pen;
if pen > 0.0 {
*count += 1;
}
}
fn avg(sum: f64, count: usize) -> f64 {
if count == 0 {
0.0
} else {
sum / count as f64
}
}
}
impl Indicator for ElderSafeZone {
type Input = Candle;
type Output = ElderSafeZoneOutput;
fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
let dp = (prev.low - candle.low).max(0.0);
let up = (candle.high - prev.high).max(0.0);
self.prev = Some(candle);
Self::push(
&mut self.down_pen,
&mut self.down_sum,
&mut self.down_count,
self.period,
dp,
);
Self::push(
&mut self.up_pen,
&mut self.up_sum,
&mut self.up_count,
self.period,
up,
);
if self.down_pen.len() < self.period {
return None;
}
let avg_down = Self::avg(self.down_sum, self.down_count);
let avg_up = Self::avg(self.up_sum, self.up_count);
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = candle.low - self.coeff * avg_down;
} else if self.direction > 0.0 {
let raw = candle.low - self.coeff * avg_down;
self.stop = self.stop.max(raw);
if candle.close < self.stop {
self.direction = -1.0;
self.stop = candle.high + self.coeff * avg_up;
}
} else {
let raw = candle.high + self.coeff * avg_up;
self.stop = self.stop.min(raw);
if candle.close > self.stop {
self.direction = 1.0;
self.stop = candle.low - self.coeff * avg_down;
}
}
let out = ElderSafeZoneOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
self.down_pen.clear();
self.up_pen.clear();
self.down_sum = 0.0;
self.up_sum = 0.0;
self.down_count = 0;
self.up_count = 0;
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ElderSafeZone"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
assert!(matches!(
ElderSafeZone::new(14, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
ElderSafeZone::new(14, -1.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let e = ElderSafeZone::new(14, 2.0).unwrap();
assert_eq!(e.params(), (14, 2.0));
assert_eq!(e.warmup_period(), 15);
assert_eq!(e.name(), "ElderSafeZone");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = e.batch(&candles);
let warmup = e.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value <= candle.close);
}
}
}
#[test]
fn noiseless_trend_stop_sits_at_low() {
// Every bar makes a higher low -> no downside penetration -> avg 0 ->
// the stop sits exactly at the bar's low.
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
let out = e.batch(&candles);
let last_candle = candles.last().unwrap();
let last = out.last().unwrap().unwrap();
assert!((last.value - last_candle.low).abs() < 1e-9);
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - f64::from(i);
c(base + 1.0, base - 1.0, base - 0.5)
}));
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let dirs: Vec<f64> = e
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
e.batch(&candles);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
assert_eq!(e.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
let mut b = ElderSafeZone::new(14, 2.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+158 -11
View File
@@ -25,7 +25,15 @@ use crate::traits::Indicator;
pub struct Ema {
period: usize,
alpha: f64,
state: Option<f64>,
/// `1 - alpha`, precomputed so the recurrence avoids a subtraction per tick.
/// Cached value, so the steady-state output is bit-for-bit unchanged.
one_minus_alpha: f64,
/// Latest EMA value, valid only once `seeded` is true. Stored as a bare `f64`
/// (plus the `seeded` flag) rather than `Option<f64>` so the steady-state
/// recurrence reads and writes 8 bytes with no enum-tag handling per tick.
current: f64,
/// Whether `current` holds a real value yet (warmup complete).
seeded: bool,
warmup_buf: Vec<f64>,
}
@@ -43,7 +51,9 @@ impl Ema {
Ok(Self {
period,
alpha,
state: None,
one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(period),
})
}
@@ -66,7 +76,9 @@ impl Ema {
Ok(Self {
period: 1,
alpha,
state: None,
one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(1),
})
}
@@ -83,21 +95,90 @@ impl Ema {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.state
if self.seeded {
Some(self.current)
} else {
None
}
}
/// Whether the EMA has seen no input yet (neither seeded nor mid-warmup).
/// Lets composite indicators (e.g. MACD) decide if a fast batch path is safe.
pub(crate) fn is_fresh(&self) -> bool {
!self.seeded && self.warmup_buf.is_empty()
}
/// Force the EMA into its seeded steady state with `current` as the latest
/// value. Used by composite fused batch paths (MACD) to leave each sub-EMA
/// where a per-tick `update` replay would, so a later `update` continues
/// correctly. The post-seed recurrence never re-reads `warmup_buf`, so it is
/// left as-is.
pub(crate) fn seed_to(&mut self, current: f64) {
self.current = current;
self.seeded = true;
}
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
///
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
/// default via inherent-method resolution. For a fresh indicator over an
/// all-finite slice it runs the seed (mean of the first `period`) once and
/// then the bare `alpha * x + (1 - alpha) * prev` recurrence in a tight loop
/// with no per-element `is_finite`/`seeded` branch and no `Option` — yet uses
/// the identical `mul_add`, so the result is *bit-for-bit* equal to replaying
/// `update`. Any other state, or a non-finite element, defers to the exact
/// `update` replay.
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
let p = self.period;
if self.seeded || !self.warmup_buf.is_empty() || !inputs.iter().all(|x| x.is_finite()) {
return inputs
.iter()
.map(|&x| self.update(x).unwrap_or(f64::NAN))
.collect();
}
let n = inputs.len();
if n < p {
// Not enough to seed; mirror `update` stashing inputs for warmup.
self.warmup_buf.extend_from_slice(inputs);
return vec![f64::NAN; n];
}
// Warmup `[0, p-1)` is `NaN`; values from the seed on are pushed once each.
let mut out = vec![f64::NAN; p - 1];
out.reserve(n - (p - 1));
let seed = inputs[..p].iter().copied().sum::<f64>() / p as f64;
let mut cur = seed;
out.push(seed);
let (alpha, oma) = (self.alpha, self.one_minus_alpha);
for &x in &inputs[p..] {
cur = alpha.mul_add(x, oma * cur);
out.push(cur);
}
// Leave state exactly where `update` would: seeded on `current`, with the
// first `period` inputs retained in `warmup_buf` (never cleared post-seed).
self.current = cur;
self.seeded = true;
self.warmup_buf.extend_from_slice(&inputs[..p]);
out
}
/// Internal helper that feeds a value without finiteness validation. The caller
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
if let Some(prev) = self.state {
let new = self.alpha.mul_add(input, (1.0 - self.alpha) * prev);
self.state = Some(new);
if self.seeded {
let new = self
.alpha
.mul_add(input, self.one_minus_alpha * self.current);
self.current = new;
return Some(new);
}
self.warmup_buf.push(input);
if self.warmup_buf.len() == self.period {
let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64;
self.state = Some(seed);
self.current = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -110,13 +191,14 @@ impl Indicator for Ema {
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.state;
return self.value();
}
self.step_unchecked(input)
}
fn reset(&mut self) {
self.state = None;
self.current = 0.0;
self.seeded = false;
self.warmup_buf.clear();
}
@@ -125,7 +207,7 @@ impl Indicator for Ema {
}
fn is_ready(&self) -> bool {
self.state.is_some()
self.seeded
}
fn name(&self) -> &'static str {
@@ -268,6 +350,71 @@ mod tests {
assert_eq!(ema.update(f64::INFINITY), before);
}
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
}
fn ema_replay(period: usize, series: &[f64]) -> Vec<f64> {
let mut e = Ema::new(period).unwrap();
series
.iter()
.map(|&x| e.update(x).unwrap_or(f64::NAN))
.collect()
}
#[test]
fn batch_nan_fast_path_is_bit_identical() {
let series: Vec<f64> = (0..300)
.map(|i| (f64::from(i) * 0.25).cos() * 8.0 + 40.0)
.collect();
let mut ema = Ema::new(14).unwrap();
let got = ema.batch_nan(&series);
assert!(bits_eq(&got, &ema_replay(14, &series)));
let mut ref_ema = Ema::new(14).unwrap();
for &x in &series {
ref_ema.update(x);
}
assert_eq!(ema.update(7.5), ref_ema.update(7.5));
}
#[test]
fn batch_nan_falls_back_on_non_finite() {
let series = [1.0, 2.0, 3.0, f64::INFINITY, 5.0, 6.0, 7.0];
let mut ema = Ema::new(3).unwrap();
assert!(bits_eq(&ema.batch_nan(&series), &ema_replay(3, &series)));
}
#[test]
fn batch_nan_falls_back_when_warming() {
let mut ema = Ema::new(3).unwrap();
ema.update(10.0); // mid-warmup: warmup_buf non-empty, not seeded
let series = [1.0, 2.0, 3.0, 4.0];
let mut ref_ema = Ema::new(3).unwrap();
ref_ema.update(10.0);
let want: Vec<f64> = series
.iter()
.map(|&x| ref_ema.update(x).unwrap_or(f64::NAN))
.collect();
assert!(bits_eq(&ema.batch_nan(&series), &want));
}
#[test]
fn batch_nan_sub_period_slice_stays_unseeded() {
let series = [1.0, 2.0];
let mut ema = Ema::new(5).unwrap();
let got = ema.batch_nan(&series);
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 2);
assert!(!ema.is_ready());
// Warmup state was stashed: feeding the rest seeds exactly as a full stream.
assert!(bits_eq(
&[ema.update(3.0).unwrap_or(f64::NAN)],
&[ema_replay(5, &[1.0, 2.0, 3.0])[2]]
));
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
@@ -0,0 +1,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_{t1}) + alpha1·HP_{t1} (one-pole highpass)
/// Filt = SuperSmoother(HP, ssf_length)
/// Wave = (Filt_t + Filt_{t1} + Filt_{t2}) / 3
/// Pwr = (Filt_t² + Filt_{t1}² + Filt_{t2}²) / 3
/// EBSW = Wave / sqrt(Pwr)
/// ```
///
/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
/// cycle by its RMS power normalises the amplitude, so the output reads like a
/// clean sine wave bounded in `[1, +1]` whatever the instrument. Unlike the
/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
/// components from the Hilbert transform and can whip in trends), the EBSW stays
/// well-behaved and is read directly: crossing up through `0`/`0.9` is a buy
/// cue, crossing down through `0`/`+0.9` a sell cue.
///
/// The first value lands once three SuperSmoothed samples exist
/// (`warmup_period == 3`). Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EvenBetterSinewave};
///
/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EvenBetterSinewave {
hp_period: usize,
ssf_length: usize,
alpha1: f64,
smoother: SuperSmoother,
prev_price: Option<f64>,
hp: f64,
filt1: Option<f64>,
filt2: Option<f64>,
filt3: Option<f64>,
last: Option<f64>,
}
impl EvenBetterSinewave {
/// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
/// `ssf_length`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either argument is `0`.
pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
if hp_period == 0 || ssf_length == 0 {
return Err(Error::PeriodZero);
}
let w = 2.0 * PI / hp_period as f64;
let alpha1 = (1.0 - w.sin()) / w.cos();
Ok(Self {
hp_period,
ssf_length,
alpha1,
smoother: SuperSmoother::new(ssf_length)?,
prev_price: None,
hp: 0.0,
filt1: None,
filt2: None,
filt3: None,
last: None,
})
}
/// Configured `(hp_period, ssf_length)`.
pub const fn params(&self) -> (usize, usize) {
(self.hp_period, self.ssf_length)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for EvenBetterSinewave {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last;
}
let hp = match self.prev_price {
Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp,
None => 0.0,
};
self.prev_price = Some(price);
self.hp = hp;
let filt = self.smoother.update(hp)?;
// Shift the three-deep filter buffer.
self.filt3 = self.filt2;
self.filt2 = self.filt1;
self.filt1 = Some(filt);
let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
return None;
};
let wave = (f1 + f2 + f3) / 3.0;
let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
let ebsw = if pwr > 0.0 {
(wave / pwr.sqrt()).clamp(-1.0, 1.0)
} else {
0.0
};
self.last = Some(ebsw);
Some(ebsw)
}
fn reset(&mut self) {
self.smoother.reset();
self.prev_price = None;
self.hp = 0.0;
self.filt1 = None;
self.filt2 = None;
self.filt3 = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"EvenBetterSinewave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_params() {
assert!(matches!(
EvenBetterSinewave::new(0, 10),
Err(Error::PeriodZero)
));
assert!(matches!(
EvenBetterSinewave::new(40, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let e = EvenBetterSinewave::new(40, 10).unwrap();
assert_eq!(e.params(), (40, 10));
assert_eq!(e.warmup_period(), 3);
assert_eq!(e.name(), "EvenBetterSinewave");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
let xs: Vec<f64> = (0..12)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
.collect();
let out = e.batch(&xs);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn output_in_range() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
.collect();
for v in e.batch(&xs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
}
}
#[test]
fn cyclic_input_swings_both_signs() {
let mut e = EvenBetterSinewave::new(30, 8).unwrap();
let xs: Vec<f64> = (0..400)
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
.collect();
let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
assert!(out.iter().any(|&v| v > 0.5));
assert!(out.iter().any(|&v| v < -0.5));
}
#[test]
fn ignores_non_finite() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
e.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
let before = e.value();
assert_eq!(e.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
e.batch(
&(0..40)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
.collect::<Vec<_>>(),
);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn batch_equals_streaming() {
let xs: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
let mut b = EvenBetterSinewave::new(40, 10).unwrap();
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_input_yields_zero_power() {
// A constant series drives the highpass/smoother outputs to zero, so the
// signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
let flat = [100.0_f64; 200];
let last = EvenBetterSinewave::new(40, 10)
.unwrap()
.batch(&flat)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
}
@@ -0,0 +1,264 @@
//! EWMA Volatility — `RiskMetrics` exponentially-weighted volatility.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// EWMA Volatility — the `RiskMetrics` exponentially-weighted estimate of the
/// volatility of log returns.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// σ²_t = λ · σ²_{t1} + (1 λ) · r²_t
/// EWMA = √σ²_t
/// ```
///
/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — an equally
/// weighted, mean-centred sample standard deviation over a fixed window — the
/// EWMA estimator weights recent squared returns geometrically by the decay
/// factor `λ`. The most recent return carries weight `1 λ`, the one before it
/// `λ(1 λ)`, and so on, so the estimate reacts to a volatility shock
/// immediately and then forgets it at rate `λ`. This is the J.P. Morgan
/// `RiskMetrics` one-parameter model; the standard daily decay is `λ = 0.94`
/// (monthly `0.97`). No mean is subtracted: squared returns *are* the variance
/// contribution, which matches the `RiskMetrics` assumption of a zero conditional
/// mean over short horizons.
///
/// The recursion is seeded with the first squared return (`σ²₁ = r²₁`) and emits
/// from the first return onward, so the very first reading is a one-observation
/// estimate that the decay then refines. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{EwmaVolatility, Indicator};
///
/// let mut indicator = EwmaVolatility::new(0.94).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EwmaVolatility {
lambda: f64,
prev_price: Option<f64>,
/// Exponentially-weighted variance of log returns; `None` until seeded.
variance: Option<f64>,
last: Option<f64>,
}
impl EwmaVolatility {
/// Construct a new EWMA-volatility indicator.
///
/// `lambda` is the decay factor, strictly between `0` and `1` (`RiskMetrics`
/// uses `0.94` for daily data). Larger `lambda` means a longer memory and a
/// smoother estimate.
///
/// # Errors
/// Returns [`Error::InvalidParameter`] if `lambda` is not finite or not in
/// the open interval `(0, 1)`.
pub fn new(lambda: f64) -> Result<Self> {
if !lambda.is_finite() || lambda <= 0.0 || lambda >= 1.0 {
return Err(Error::InvalidParameter {
message: "EWMA volatility lambda must be in the open interval (0, 1)",
});
}
Ok(Self {
lambda,
prev_price: None,
variance: None,
last: None,
})
}
/// Configured decay factor.
pub const fn lambda(&self) -> f64 {
self.lambda
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for EwmaVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the variance recursion.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
let var = match self.variance {
// Seed the recursion with the first squared return.
None => r * r,
Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r,
};
self.variance = Some(var);
// `var` is a convex combination of non-negative terms, but rounding can
// leave a tiny negative residual when every return is ~0; clamp first.
let vol = var.max(0.0).sqrt();
self.last = Some(vol);
Some(vol)
}
fn reset(&mut self) {
self.prev_price = None;
self.variance = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price; the estimate is seeded
// and emitted on that first return.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"EwmaVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_lambda() {
for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] {
assert!(matches!(
EwmaVolatility::new(bad),
Err(Error::InvalidParameter { .. })
));
}
}
#[test]
fn accessors_and_metadata() {
let ewma = EwmaVolatility::new(0.94).unwrap();
assert_relative_eq!(ewma.lambda(), 0.94);
assert_eq!(ewma.warmup_period(), 2);
assert_eq!(ewma.name(), "EwmaVolatility");
assert!(!ewma.is_ready());
assert_eq!(ewma.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
assert_eq!(ewma.update(100.0), None);
let out = ewma.update(110.0);
assert!(out.is_some());
assert!(ewma.is_ready());
}
#[test]
fn known_value() {
// r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then
// σ²₂ = λ·r1² + (1−λ)·r2².
let lambda = 0.94;
let mut ewma = EwmaVolatility::new(lambda).unwrap();
let out = ewma.batch(&[100.0, 110.0, 99.0]);
let r1 = (110.0_f64 / 100.0).ln();
let r2 = (99.0_f64 / 110.0).ln();
assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12);
let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2;
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut ewma = EwmaVolatility::new(0.9).unwrap();
for v in ewma.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in ewma.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let out = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(ewma.update(f64::NAN), last);
assert_eq!(ewma.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let warmup = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(ewma.update(-5.0), Some(baseline));
assert_eq!(ewma.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = ewma.clone();
let after = ewma.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_price() {
// The skip guard fires before any previous price exists.
let mut ewma = EwmaVolatility::new(0.94).unwrap();
assert_eq!(ewma.update(0.0), None);
assert_eq!(ewma.update(f64::NAN), None);
assert_eq!(ewma.update(100.0), None);
assert!(ewma.update(110.0).is_some());
}
#[test]
fn reset_clears_state() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ewma.is_ready());
ewma.reset();
assert!(!ewma.is_ready());
assert_eq!(ewma.value(), None);
assert_eq!(ewma.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices);
let mut b = EwmaVolatility::new(0.94).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,208 @@
//! Expectancy — expected return per unit of average loss (R-multiple).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Expectancy — the expected return per trade expressed in units of average
/// loss (the "R-multiple" expectancy) over the last `period` returns.
///
/// ```text
/// mean = average of the `period` returns
/// avgLoss = average of the absolute losing returns (rᵢ < 0)
/// E = mean / avgLoss (0 when there are no losing returns)
/// ```
///
/// Feed a stream of per-trade or per-bar returns. Expectancy answers "how much
/// do I make per trade for every unit I typically risk": `E = 0.3` means the
/// system nets `0.3R` per trade on average, where `R` is the average loss.
/// Dividing the mean return by the average loss makes the figure comparable
/// across systems with different bet sizes — unlike the raw mean return (which
/// is just an SMA of the series). A positive `E` is a profitable edge, a
/// negative `E` a losing one.
///
/// When the window contains **no** losing returns there is no risk reference to
/// normalise against, so the indicator returns `0` (undefined R-multiple)
/// rather than dividing by zero.
///
/// Each `update` is O(1): the running sum and the loss aggregates are
/// maintained incrementally.
///
/// # Example
///
/// ```
/// use wickra_core::{BatchExt, Indicator, Expectancy};
///
/// let mut indicator = Expectancy::new(4).unwrap();
/// // returns +2, -1, +2, -1: mean 0.5, avg loss 1 -> E = 0.5.
/// let out = indicator.batch(&[2.0, -1.0, 2.0, -1.0]);
/// assert_eq!(out[3], Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct Expectancy {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_abs_loss: f64,
loss_count: usize,
}
impl Expectancy {
/// Construct a new Expectancy over the given window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_abs_loss: 0.0,
loss_count: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Expectancy {
type Input = f64;
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
if old < 0.0 {
self.sum_abs_loss -= -old;
self.loss_count -= 1;
}
}
self.window.push_back(ret);
self.sum += ret;
if ret < 0.0 {
self.sum_abs_loss += -ret;
self.loss_count += 1;
}
if self.window.len() < self.period {
return None;
}
if self.loss_count == 0 {
// No losing returns: no risk reference to express the edge in.
return Some(0.0);
}
let mean = self.sum / self.period as f64;
let avg_loss = self.sum_abs_loss / self.loss_count as f64;
Some(mean / avg_loss)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_abs_loss = 0.0;
self.loss_count = 0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Expectancy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Expectancy::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let e = Expectancy::new(20).unwrap();
assert_eq!(e.period(), 20);
assert_eq!(e.warmup_period(), 20);
assert_eq!(e.name(), "Expectancy");
assert!(!e.is_ready());
}
#[test]
fn positive_edge() {
// +2, -1, +2, -1: mean 0.5, avgLoss 1 -> 0.5.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn negative_edge() {
// +1, -2, +1, -2: mean -0.5, avgLoss 2 -> -0.25.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[1.0, -2.0, 1.0, -2.0]);
assert_relative_eq!(out[3].unwrap(), -0.25, epsilon = 1e-12);
}
#[test]
fn no_losses_returns_zero() {
// All winning returns: no risk reference -> 0.
let mut e = Expectancy::new(5).unwrap();
for v in e.batch(&[1.0, 2.0, 3.0, 1.0, 2.0]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn flat_returns_are_not_losses() {
// Zeros are not losses: mean (2+0+2+0)/4 = 1, but no losing returns
// -> 0 (undefined R-multiple).
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, 0.0, 2.0, 0.0]);
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_old_losses() {
// period 4. Window [+2,-1,+2,-1] -> 0.5; then push +3,+3,+3,+3 to evict
// all losses -> no losses -> 0.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0, 3.0, 3.0, 3.0, 3.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
assert_relative_eq!(out[7].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut e = Expectancy::new(5).unwrap();
e.batch(&[1.0, -1.0, 2.0, -2.0, 1.0]);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
let batch = Expectancy::new(14).unwrap().batch(&rets);
let mut b = Expectancy::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,198 @@
//! Fibonacci Arcs — semicircular retracement levels centred on the swing end,
//! decaying back toward it as time elapses.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The three arc ratios drawn (38.2% / 50% / 61.8%).
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// Fibonacci Arc prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibArcsOutput {
/// Price of the 38.2% arc at the current bar.
pub arc_382: f64,
/// Price of the 50% arc at the current bar.
pub arc_500: f64,
/// Price of the 61.8% arc at the current bar.
pub arc_618: f64,
}
/// Fibonacci Arcs (`FibArcs`).
///
/// Three arcs centred on the end of the most recent confirmed swing leg. Time is
/// normalised by the leg's bar-width so the construction is chart-scale-free: at
/// the leg's end bar each arc sits exactly on its retracement level, and as time
/// elapses the arc curves back toward the swing-end price, reaching it one leg
/// width later.
///
/// ```text
/// u = (cur - end_bar) / (end_bar - start_bar)
/// arc(r) = end + (start - end) * r * sqrt(max(0, 1 - u^2))
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_arcs.rs`.
#[derive(Debug, Clone)]
pub struct FibArcs {
swing: SwingTracker,
}
impl FibArcs {
/// Construct a new Fibonacci Arcs tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn arcs(&self) -> Option<FibArcsOutput> {
let pivots = self.swing.pivots();
let start = pivots.first()?;
let end = pivots.get(1)?;
// Consecutive pivots occur at strictly increasing bars → span >= 1 bar.
let span_bars = (end.bar - start.bar) as f64;
let u = (self.swing.current_bar() - end.bar) as f64 / span_bars;
let curve = (1.0 - u * u).max(0.0).sqrt();
let arc = |r: f64| end.price + (start.price - end.price) * r * curve;
Some(FibArcsOutput {
arc_382: arc(RATIOS[0]),
arc_500: arc(RATIOS[1]),
arc_618: arc(RATIOS[2]),
})
}
}
impl Default for FibArcs {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibArcs {
type Input = Candle;
type Output = FibArcsOutput;
fn update(&mut self, candle: Candle) -> Option<FibArcsOutput> {
self.swing.update(candle);
self.arcs()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibArcs"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3 so the arc is
/// first reported with `u = (3 - 2) / (2 - 0) = 0.5`.
fn down_leg() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0),
c(190.0, 160.0, 1), // confirm high @200
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibArcs::new();
assert_eq!(indicator.name(), "FibArcs");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibArcs::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibArcs::new();
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn arcs_curve_back_toward_the_swing_end() {
let mut indicator = FibArcs::new();
let mut last = None;
for candle in down_leg() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// u = 0.5 → curve = sqrt(0.75); arc(r) = 100 + 100 * r * curve.
let curve = 0.75_f64.sqrt();
assert_relative_eq!(v.arc_382, 100.0 + 100.0 * 0.382 * curve);
assert_relative_eq!(v.arc_500, 100.0 + 100.0 * 0.5 * curve);
assert_relative_eq!(v.arc_618, 100.0 + 100.0 * 0.618 * curve);
}
#[test]
fn arc_clamps_to_zero_beyond_one_leg_width() {
// Extend far past the end pivot so u > 1; the curve clamps to 0 and the
// arcs collapse onto the swing-end price.
let mut indicator = FibArcs::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
// Feed flat bars that neither extend nor confirm a new pivot.
let mut last = None;
for ts in 4..12 {
last = indicator.update(c(108.0, 106.0, ts));
}
let v = last.unwrap();
assert_relative_eq!(v.arc_382, 100.0);
assert_relative_eq!(v.arc_618, 100.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibArcs::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = down_leg();
let mut a = FibArcs::new();
let mut b = FibArcs::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,192 @@
//! Fibonacci Channel — a sloped base trendline plus parallel lines offset by
//! Fibonacci multiples of the channel width.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The parallel-line ratios above the base (61.8% / 100% / 161.8% of the width).
const RATIOS: [f64; 3] = [0.618, 1.0, 1.618];
/// Fibonacci Channel line prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibChannelOutput {
/// The base trendline price at the current bar.
pub base: f64,
/// Base + 61.8% of the channel width.
pub level_618: f64,
/// Base + 100% of the width — the opposite channel boundary.
pub level_1000: f64,
/// Base + 161.8% of the width.
pub level_1618: f64,
}
/// Fibonacci Channel (`FibChannel`).
///
/// From the last three confirmed pivots, the two same-direction outer pivots
/// define a sloped base trendline and the opposite middle pivot sets the channel
/// width (its signed distance from the base line). Parallel lines are then offset
/// by Fibonacci multiples of that width and reported at the current bar.
///
/// ```text
/// slope = (p2 - p0) / (bar2 - bar0)
/// base(bar) = p0 + slope * (bar - bar0)
/// width = p1 - base(bar1)
/// level(r) = base(cur) + r * width
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until three pivots
/// have confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_channel.rs`.
#[derive(Debug, Clone)]
pub struct FibChannel {
swing: SwingTracker,
}
impl FibChannel {
/// Construct a new Fibonacci Channel tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
}
}
fn channel(&self) -> Option<FibChannelOutput> {
let pivots = self.swing.pivots();
let p0 = pivots.first()?;
let p1 = pivots.get(1)?;
let p2 = pivots.get(2)?;
// p0 and p2 are the same-direction outer pivots; their bars differ
// strictly, so the slope denominator is non-zero.
let slope = (p2.price - p0.price) / (p2.bar - p0.bar) as f64;
let base_at = |bar: usize| p0.price + slope * (bar - p0.bar) as f64;
let width = p1.price - base_at(p1.bar);
let base = base_at(self.swing.current_bar());
Some(FibChannelOutput {
base,
level_618: base + RATIOS[0] * width,
level_1000: base + RATIOS[1] * width,
level_1618: base + RATIOS[2] * width,
})
}
}
impl Default for FibChannel {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibChannel {
type Input = Candle;
type Output = FibChannelOutput;
fn update(&mut self, candle: Candle) -> Option<FibChannelOutput> {
self.swing.update(candle);
self.channel()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Pivots: high 200 (bar 0), low 100 (bar 1), high 220 (bar 3); confirmed at
/// bar 4 so the channel is first reported at current bar 4.
fn three_pivots() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0),
c(190.0, 100.0, 1), // confirm high @200, low candidate @100
c(110.0, 108.0, 2), // confirm low @100, high candidate @110
c(220.0, 210.0, 3), // extend high to 220 (bar 3)
c(200.0, 150.0, 4), // confirm high @220 -> three pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibChannel::new();
assert_eq!(indicator.name(), "FibChannel");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibChannel::default().is_ready());
}
#[test]
fn no_output_before_three_pivots() {
let mut indicator = FibChannel::new();
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 100.0, 1), c(110.0, 108.0, 2)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
// Only two pivots confirm within these three bars.
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn channel_levels_from_three_pivots() {
let mut indicator = FibChannel::new();
let mut last = None;
for candle in three_pivots() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// Base through highs (0,200) and (3,220); width from low (1,100); cur = 4.
let slope = (220.0 - 200.0) / 3.0;
let base_cur = 200.0 + slope * 4.0;
let width = 100.0 - (200.0 + slope * 1.0);
assert_relative_eq!(v.base, base_cur);
assert_relative_eq!(v.level_1000, base_cur + width);
assert_relative_eq!(v.level_618, base_cur + 0.618 * width);
assert_relative_eq!(v.level_1618, base_cur + 1.618 * width);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibChannel::new();
for candle in three_pivots() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = three_pivots();
let mut a = FibChannel::new();
let mut b = FibChannel::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,181 @@
//! Fibonacci Confluence — the strongest retracement cluster across recent legs.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// How many recent pivots to consider; six pivots yield up to five legs.
const PIVOT_HISTORY: usize = 6;
/// The retracement ratios contributed by each leg to the confluence search.
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// The strongest Fibonacci confluence zone found across recent swing legs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibConfluenceOutput {
/// Mean price of the densest cluster of retracement levels.
pub price: f64,
/// Number of retracement levels that fall inside the cluster (its strength).
pub strength: f64,
}
/// Fibonacci Confluence (`FibConfluence`).
///
/// Computes the 38.2% / 50% / 61.8% retracement prices of every leg among the
/// last six confirmed pivots, then reports the densest price cluster — where
/// levels from different legs stack up, the zone the market is most likely to
/// react to. `price` is the cluster mean; `strength` is how many levels it
/// gathers.
///
/// Parameter-free; construction is infallible. Returns `None` until at least two
/// legs (three pivots) exist.
///
/// See `crates/wickra-core/src/indicators/fib_confluence.rs`.
#[derive(Debug, Clone)]
pub struct FibConfluence {
swing: SwingTracker,
}
impl FibConfluence {
/// Construct a new Fibonacci Confluence tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
}
}
fn confluence(&self) -> Option<FibConfluenceOutput> {
let pivots = self.swing.pivots();
if pivots.len() < 3 {
return None;
}
let levels: Vec<f64> = pivots
.windows(2)
.flat_map(|leg| {
let (start, end) = (leg[0].price, leg[1].price);
RATIOS.map(|r| end + r * (start - end))
})
.collect();
// The `len < 3` guard guarantees at least two legs, hence a non-empty
// level set, so `max_by` always yields a cluster.
let (count, total) = levels
.iter()
.map(|&center| {
let members: Vec<f64> = levels
.iter()
.copied()
.filter(|&x| approx_equal(x, center, LEVEL_TOLERANCE))
.collect();
(members.len(), members.iter().sum::<f64>())
})
.max_by(|a, b| a.0.cmp(&b.0))
.expect("at least two legs guarantee a non-empty level set");
Some(FibConfluenceOutput {
price: total / count as f64,
strength: count as f64,
})
}
}
impl Default for FibConfluence {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibConfluence {
type Input = Candle;
type Output = FibConfluenceOutput;
fn update(&mut self, candle: Candle) -> Option<FibConfluenceOutput> {
self.swing.update(candle);
self.confluence()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibConfluence"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibConfluence::new();
assert_eq!(indicator.name(), "FibConfluence");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibConfluence::default().is_ready());
}
#[test]
fn no_output_before_two_legs() {
let mut indicator = FibConfluence::new();
let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn picks_the_densest_cluster() {
// Legs 200->100 and 100->160. The 38.2% of each (138.2 and ~137.08)
// sit within 3% of each other and form the densest cluster (strength 2).
let mut indicator = FibConfluence::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
assert_relative_eq!(v.strength, 2.0);
let want = (138.2 + (160.0 + 0.382 * (100.0 - 160.0))) / 2.0;
assert_relative_eq!(v.price, want, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibConfluence::new();
for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 160.0, 120.0]);
let mut a = FibConfluence::new();
let mut b = FibConfluence::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,171 @@
//! Fibonacci Extension of the most recent confirmed swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The five canonical extension ratios, in ascending order. Each is a multiple
/// of the swing leg measured from its origin, so `1.0` sits on the leg's end and
/// every ratio here projects further in the direction of the move.
const RATIOS: [f64; 5] = [1.272, 1.414, 1.618, 2.0, 2.618];
/// Fibonacci Extension levels for the most recent swing leg.
///
/// Each field is the price reached if the move continues to the matching
/// multiple of the leg, measured from the leg's start.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibExtensionOutput {
/// 127.2% extension.
pub level_1272: f64,
/// 141.4% extension.
pub level_1414: f64,
/// 161.8% extension — the "golden" extension.
pub level_1618: f64,
/// 200% extension.
pub level_2000: f64,
/// 261.8% extension.
pub level_2618: f64,
}
/// Fibonacci Extension (`FibExtension`).
///
/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold and, once
/// two pivots exist, projects the leg between them to the canonical extension
/// ratios — the price targets a continuation of the move would reach.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_extension.rs`.
#[derive(Debug, Clone)]
pub struct FibExtension {
swing: SwingTracker,
}
impl FibExtension {
/// Construct a new Fibonacci Extension tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
/// Extension price at ratio `e` for a leg from `start` to `end`: the total
/// move is `e` times the leg, measured from `start`.
fn level(start: f64, end: f64, e: f64) -> f64 {
start + e * (end - start)
}
fn levels(&self) -> Option<FibExtensionOutput> {
let pivots = self.swing.pivots();
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
Some(FibExtensionOutput {
level_1272: Self::level(start, end, RATIOS[0]),
level_1414: Self::level(start, end, RATIOS[1]),
level_1618: Self::level(start, end, RATIOS[2]),
level_2000: Self::level(start, end, RATIOS[3]),
level_2618: Self::level(start, end, RATIOS[4]),
})
}
}
impl Default for FibExtension {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibExtension {
type Input = Candle;
type Output = FibExtensionOutput;
fn update(&mut self, candle: Candle) -> Option<FibExtensionOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibExtension"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibExtension::new();
assert_eq!(indicator.name(), "FibExtension");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibExtension::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibExtension::new();
let outputs: Vec<_> = candles_for_pivots(&[120.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
}
#[test]
fn extension_levels_of_a_down_leg() {
// Leg start = 200 (high), end = 100 (low): a 100-point drop continued.
let mut indicator = FibExtension::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// 161.8% extension projects 1.618 * (-100) below the 200 origin.
assert_relative_eq!(v.level_1272, 200.0 - 127.2);
assert_relative_eq!(v.level_1414, 200.0 - 141.4);
assert_relative_eq!(v.level_1618, 200.0 - 161.8);
assert_relative_eq!(v.level_2000, 0.0);
assert_relative_eq!(v.level_2618, 200.0 - 261.8);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibExtension::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
let mut a = FibExtension::new();
let mut b = FibExtension::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,180 @@
//! Fibonacci Fan — trendlines fanning from a swing start through the
//! retracement levels at the swing end, extended to the current bar.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The three fan ratios drawn (38.2% / 50% / 61.8%).
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
/// Fibonacci Fan line prices evaluated at the current bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibFanOutput {
/// Price of the 38.2% fan line at the current bar.
pub fan_382: f64,
/// Price of the 50% fan line at the current bar.
pub fan_500: f64,
/// Price of the 61.8% fan line at the current bar.
pub fan_618: f64,
}
/// Fibonacci Fan (`FibFan`).
///
/// Anchored at the start of the most recent confirmed swing leg, three lines fan
/// out through the 38.2% / 50% / 61.8% retracement levels located at the leg's
/// end bar, then extend to the current bar. Each line's price is reported as the
/// fan opens with elapsed time.
///
/// ```text
/// line(r) = start + r * (end - start) * (cur - start_bar) / (end_bar - start_bar)
/// ```
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/fib_fan.rs`.
#[derive(Debug, Clone)]
pub struct FibFan {
swing: SwingTracker,
}
impl FibFan {
/// Construct a new Fibonacci Fan tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn fan(&self) -> Option<FibFanOutput> {
let pivots = self.swing.pivots();
let start = pivots.first()?;
let end = pivots.get(1)?;
// Consecutive pivots occur at strictly increasing bars, so the span is
// always at least one bar — no division by zero.
let span_bars = (end.bar - start.bar) as f64;
let elapsed = (self.swing.current_bar() - start.bar) as f64;
let progress = elapsed / span_bars;
let line = |r: f64| start.price + r * (end.price - start.price) * progress;
Some(FibFanOutput {
fan_382: line(RATIOS[0]),
fan_500: line(RATIOS[1]),
fan_618: line(RATIOS[2]),
})
}
}
impl Default for FibFan {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibFan {
type Input = Candle;
type Output = FibFanOutput;
fn update(&mut self, candle: Candle) -> Option<FibFanOutput> {
self.swing.update(candle);
self.fan()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibFan"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// Drive a leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3, so
/// the fan is first reported at bar 3 with `progress = 3 / 2 = 1.5`.
fn down_leg() -> Vec<Candle> {
vec![
c(200.0, 199.0, 0), // bootstrap high @200 (bar 0)
c(190.0, 160.0, 1), // confirm high @200, low candidate @160
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
]
}
#[test]
fn accessors_and_metadata() {
let indicator = FibFan::new();
assert_eq!(indicator.name(), "FibFan");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibFan::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibFan::new();
// Only the high confirms here; no end pivot yet.
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn fan_lines_open_with_elapsed_time() {
let mut indicator = FibFan::new();
let mut last = None;
for candle in down_leg() {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// progress = (3 - 0) / (2 - 0) = 1.5; line(r) = 200 + r*(-100)*1.5.
assert_relative_eq!(v.fan_382, 200.0 - 0.382 * 150.0);
assert_relative_eq!(v.fan_500, 125.0);
assert_relative_eq!(v.fan_618, 200.0 - 0.618 * 150.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibFan::new();
for candle in down_leg() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = down_leg();
let mut a = FibFan::new();
let mut b = FibFan::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,165 @@
//! Fibonacci Projection — a measured move from the last three swing pivots.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The four canonical projection ratios, in ascending order. Each scales the
/// A→B leg and projects it from C; `1.0` is the classic AB=CD measured move.
const RATIOS: [f64; 4] = [0.618, 1.0, 1.618, 2.618];
/// Fibonacci Projection levels (the C→D target zone of a measured move).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibProjectionOutput {
/// 61.8% projection of the A→B leg from C.
pub level_618: f64,
/// 100% projection — the AB=CD measured move.
pub level_1000: f64,
/// 161.8% projection.
pub level_1618: f64,
/// 261.8% projection.
pub level_2618: f64,
}
/// Fibonacci Projection (`FibProjection`).
///
/// Reads the last three confirmed swing pivots as the points A, B and C of a
/// measured move and projects the A→B leg from C at the canonical ratios — the
/// price targets for the C→D leg.
///
/// Parameter-free; construction is infallible. Returns `None` until three
/// pivots have confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_projection.rs`.
#[derive(Debug, Clone)]
pub struct FibProjection {
swing: SwingTracker,
}
impl FibProjection {
/// Construct a new Fibonacci Projection tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
}
}
fn levels(&self) -> Option<FibProjectionOutput> {
let pivots = self.swing.pivots();
let [a, b, c] = [
pivots.first()?.price,
pivots.get(1)?.price,
pivots.get(2)?.price,
];
let project = |p: f64| c + p * (b - a);
Some(FibProjectionOutput {
level_618: project(RATIOS[0]),
level_1000: project(RATIOS[1]),
level_1618: project(RATIOS[2]),
level_2618: project(RATIOS[3]),
})
}
}
impl Default for FibProjection {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibProjection {
type Input = Candle;
type Output = FibProjectionOutput;
fn update(&mut self, candle: Candle) -> Option<FibProjectionOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 3
}
fn name(&self) -> &'static str {
"FibProjection"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibProjection::new();
assert_eq!(indicator.name(), "FibProjection");
assert_eq!(indicator.warmup_period(), 3);
assert!(!indicator.is_ready());
assert!(!FibProjection::default().is_ready());
}
#[test]
fn no_output_before_three_pivots() {
let mut indicator = FibProjection::new();
let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn measured_move_from_three_pivots() {
// A = 200 (high), B = 160 (low), C = 190 (high). A->B = -40, projected
// down from C.
let mut indicator = FibProjection::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
let (a, b, c) = (200.0, 160.0, 190.0);
assert_relative_eq!(v.level_618, c + 0.618 * (b - a));
assert_relative_eq!(v.level_1000, c + (b - a));
assert_relative_eq!(v.level_1618, c + 1.618 * (b - a));
assert_relative_eq!(v.level_2618, c + 2.618 * (b - a));
}
#[test]
fn reset_clears_state() {
let mut indicator = FibProjection::new();
for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 160.0, 190.0, 150.0]);
let mut a = FibProjection::new();
let mut b = FibProjection::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,201 @@
//! Fibonacci Retracement of the most recent confirmed swing leg.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The seven canonical retracement ratios, in ascending order. `0.0` marks the
/// most recent swing extreme (the end of the leg) and `1.0` the swing origin
/// (its start); the interior ratios are the classic Fibonacci pullbacks.
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
/// Fibonacci Retracement levels for the most recent swing leg.
///
/// Each field is the price at the matching retracement ratio, measured from the
/// leg's end (`level_0`, the latest confirmed extreme) back toward its start
/// (`level_1000`, the prior pivot).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibRetracementOutput {
/// 0.0% — the most recent confirmed swing extreme.
pub level_0: f64,
/// 23.6% retracement.
pub level_236: f64,
/// 38.2% retracement.
pub level_382: f64,
/// 50% retracement (not a Fibonacci ratio, but conventionally drawn).
pub level_500: f64,
/// 61.8% retracement — the "golden ratio" pullback.
pub level_618: f64,
/// 78.6% retracement.
pub level_786: f64,
/// 100% — the swing origin.
pub level_1000: f64,
}
/// Fibonacci Retracement (`FibRetracement`).
///
/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold (the
/// same non-repainting logic as [`crate::indicators::ZigZag`]) and, once two
/// pivots exist, reports the seven retracement levels of the leg between them.
///
/// The levels are recomputed each time a new pivot confirms; between
/// confirmations [`Indicator::update`] returns the locked levels of the current
/// leg. Before the first leg is complete it returns `None`.
///
/// Parameter-free: the threshold is a compile-time constant, mirroring the
/// chart- and harmonic-pattern detectors, so construction is infallible.
///
/// See `crates/wickra-core/src/indicators/fib_retracement.rs`.
#[derive(Debug, Clone)]
pub struct FibRetracement {
swing: SwingTracker,
}
impl FibRetracement {
/// Construct a new Fibonacci Retracement tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
/// Retracement price at ratio `r` for a leg from `start` to `end`: `0.0`
/// sits on `end`, `1.0` on `start`.
fn level(start: f64, end: f64, r: f64) -> f64 {
end + r * (start - end)
}
fn levels(&self) -> Option<FibRetracementOutput> {
let pivots = self.swing.pivots();
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
Some(FibRetracementOutput {
level_0: Self::level(start, end, RATIOS[0]),
level_236: Self::level(start, end, RATIOS[1]),
level_382: Self::level(start, end, RATIOS[2]),
level_500: Self::level(start, end, RATIOS[3]),
level_618: Self::level(start, end, RATIOS[4]),
level_786: Self::level(start, end, RATIOS[5]),
level_1000: Self::level(start, end, RATIOS[6]),
})
}
}
impl Default for FibRetracement {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibRetracement {
type Input = Candle;
type Output = FibRetracementOutput;
fn update(&mut self, candle: Candle) -> Option<FibRetracementOutput> {
self.swing.update(candle);
self.levels()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"FibRetracement"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = FibRetracement::new();
assert_eq!(indicator.name(), "FibRetracement");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibRetracement::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = FibRetracement::new();
// A single confirmed pivot is not enough to define a leg.
let candles = candles_for_pivots(&[120.0]);
let outputs: Vec<_> = candles.into_iter().map(|c| indicator.update(c)).collect();
assert!(outputs.iter().all(Option::is_none));
assert!(!indicator.is_ready());
}
#[test]
fn retracement_levels_of_a_down_leg() {
// Leg start = 200 (high), end = 100 (low): a 100-point drop.
let mut indicator = FibRetracement::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// 0% on the low (end), 100% on the high (start).
assert_relative_eq!(v.level_0, 100.0);
assert_relative_eq!(v.level_1000, 200.0);
// 61.8% retracement of a 100-point drop, measured up from the low.
assert_relative_eq!(v.level_618, 161.8);
assert_relative_eq!(v.level_500, 150.0);
assert_relative_eq!(v.level_382, 138.2);
assert_relative_eq!(v.level_236, 123.6);
assert_relative_eq!(v.level_786, 178.6);
}
#[test]
fn levels_refresh_on_a_new_leg() {
// Four pivots, cap = 2: once the third and fourth confirm, the reported
// leg shifts to the latest pair (130 high -> 90 low).
let mut indicator = FibRetracement::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0, 130.0, 90.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert_relative_eq!(v.level_0, 90.0);
assert_relative_eq!(v.level_1000, 130.0);
assert_relative_eq!(v.level_618, 90.0 + 0.618 * 40.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FibRetracement::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
let mut a = FibRetracement::new();
let mut b = FibRetracement::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,181 @@
//! Fibonacci Time Zones — vertical markers at Fibonacci bar-distances from the
//! most recent swing pivot.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Where the current bar sits relative to the Fibonacci time-zone grid anchored
/// on the most recent confirmed pivot.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FibTimeZonesOutput {
/// `1.0` when the current bar lands on a Fibonacci time zone (a bar distance
/// of 1, 2, 3, 5, 8, 13, … from the anchor pivot), otherwise `0.0`.
pub on_zone: f64,
/// Number of bars until the next Fibonacci time zone (`0` is never returned —
/// when on a zone this is the gap to the following one).
pub bars_to_next: f64,
}
/// Fibonacci Time Zones (`FibTimeZones`).
///
/// Anchored on the most recent confirmed swing pivot, the Fibonacci sequence
/// `1, 2, 3, 5, 8, 13, …` marks bars at which trend changes are classically
/// anticipated. Reports whether the current bar is on a zone and how many bars
/// remain until the next one.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// pivot has confirmed.
///
/// See `crates/wickra-core/src/indicators/fib_time_zones.rs`.
#[derive(Debug, Clone)]
pub struct FibTimeZones {
swing: SwingTracker,
}
impl FibTimeZones {
/// Construct a new Fibonacci Time Zones tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn zones(&self) -> Option<FibTimeZonesOutput> {
let anchor = self.swing.pivots().last()?;
let distance = self.swing.current_bar() - anchor.bar;
// Walk the time-zone sequence 1, 2, 3, 5, 8, … : `lo` advances through the
// members, `on_zone` records a hit, and the loop exits with `lo` holding
// the smallest member strictly greater than `distance`.
let (mut lo, mut hi) = (1usize, 2usize);
let mut on_zone = false;
while lo <= distance {
if lo == distance {
on_zone = true;
}
let next = lo + hi;
lo = hi;
hi = next;
}
Some(FibTimeZonesOutput {
on_zone: f64::from(u8::from(on_zone)),
bars_to_next: (lo - distance) as f64,
})
}
}
impl Default for FibTimeZones {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FibTimeZones {
type Input = Candle;
type Output = FibTimeZonesOutput;
fn update(&mut self, candle: Candle) -> Option<FibTimeZonesOutput> {
self.swing.update(candle);
self.zones()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
!self.swing.pivots().is_empty()
}
fn name(&self) -> &'static str {
"FibTimeZones"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
Candle::new(low, high, low, low, 1.0, ts).unwrap()
}
/// One pivot confirms at bar 0 (high @200, confirmed at bar 1); subsequent
/// flat bars neither extend nor confirm, so the anchor stays at bar 0 and the
/// distance equals the current bar index.
fn anchored_run() -> Vec<Candle> {
let mut bars = vec![c(200.0, 199.0, 0), c(190.0, 150.0, 1)];
for ts in 2..=5 {
bars.push(c(155.0, 151.0, ts));
}
bars
}
#[test]
fn accessors_and_metadata() {
let indicator = FibTimeZones::new();
assert_eq!(indicator.name(), "FibTimeZones");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!FibTimeZones::default().is_ready());
}
#[test]
fn no_output_before_first_pivot() {
let mut indicator = FibTimeZones::new();
// The bootstrap bar confirms nothing.
assert!(indicator.update(c(200.0, 199.0, 0)).is_none());
assert!(!indicator.is_ready());
}
#[test]
fn flags_zones_and_counts_to_next() {
let mut indicator = FibTimeZones::new();
let out: Vec<_> = anchored_run()
.into_iter()
.map(|x| indicator.update(x))
.collect();
assert!(out[0].is_none()); // bootstrap, no pivot yet
assert!(indicator.is_ready());
// out[i] is reported at current bar i; anchor at bar 0 → distance = i.
let d1 = out[1].unwrap(); // distance 1 → a zone
assert_relative_eq!(d1.on_zone, 1.0);
assert_relative_eq!(d1.bars_to_next, 1.0); // next zone at 2
let d4 = out[4].unwrap(); // distance 4 → not a zone
assert_relative_eq!(d4.on_zone, 0.0);
assert_relative_eq!(d4.bars_to_next, 1.0); // next zone at 5
let d5 = out[5].unwrap(); // distance 5 → a zone
assert_relative_eq!(d5.on_zone, 1.0);
assert_relative_eq!(d5.bars_to_next, 3.0); // next zone at 8
}
#[test]
fn reset_clears_state() {
let mut indicator = FibTimeZones::new();
for candle in anchored_run() {
let _ = indicator.update(candle);
}
assert!(indicator.is_ready());
indicator.reset();
assert!(!indicator.is_ready());
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = anchored_run();
let mut a = FibTimeZones::new();
let mut b = FibTimeZones::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,186 @@
//! Fisher-transformed RSI.
use crate::error::Result;
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
///
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
/// peaks:
///
/// ```text
/// rsi = RSI(price, period) in [0, 100]
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
/// ```
///
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
/// sharp excursions marking momentum extremes. The first value lands with the
/// inner RSI, after `period + 1` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{FisherRsi, Indicator};
///
/// let mut indicator = FisherRsi::new(9).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 FisherRsi {
period: usize,
rsi: Rsi,
}
impl FisherRsi {
/// Construct a Fisher RSI with the given RSI period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
rsi: Rsi::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for FisherRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
}
fn reset(&mut self) {
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.rsi.warmup_period()
}
fn is_ready(&self) -> bool {
self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"FisherRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(FisherRsi::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let f = FisherRsi::new(9).unwrap();
assert_eq!(f.period(), 9);
// RSI warmup is period + 1.
assert_eq!(f.warmup_period(), 10);
assert_eq!(f.name(), "FisherRSI");
}
#[test]
fn warmup_matches_rsi() {
let mut f = FisherRsi::new(3).unwrap();
// RSI(3) needs 4 inputs; the first three return None.
assert_eq!(f.update(1.0), None);
assert_eq!(f.update(2.0), None);
assert_eq!(f.update(3.0), None);
assert!(f.update(4.0).is_some());
}
#[test]
fn matches_fisher_of_rsi() {
// Fisher RSI must equal the Fisher transform of the standalone RSI.
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut fr = FisherRsi::new(9).unwrap();
let mut rsi = Rsi::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = fr.update(p);
let want = rsi.update(p).map(|r| {
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
0.5 * ((1.0 + x) / (1.0 - x)).ln()
});
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-12);
}
}
}
#[test]
fn strong_uptrend_is_positive() {
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut f = FisherRsi::new(9).unwrap();
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 1.0,
"strong uptrend should give a large positive value, got {last}"
);
}
#[test]
fn clamp_keeps_output_finite_at_extremes() {
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
let mut f = FisherRsi::new(5).unwrap();
for v in f.batch(&prices).into_iter().flatten() {
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut f = FisherRsi::new(5).unwrap();
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(f.is_ready());
f.reset();
assert!(!f.is_ready());
assert_eq!(f.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = FisherRsi::new(9).unwrap();
let mut b = FisherRsi::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,162 @@
//! Flag / Pennant continuation chart pattern.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Maximum size of the consolidation swing relative to the pole for a
/// flag/pennant to qualify — the pullback must retrace less than half the pole.
const MAX_RETRACE_FRACTION: f64 = 0.5;
/// Flag / Pennant — a brief consolidation against a sharp prior move (the
/// "pole"), resolving in the pole's direction.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the
/// last three pivots `pole_start → pole_end → consolidation`:
///
/// ```text
/// pole = |pole_end pole_start| (the sharp impulse)
/// pullback = |consolidation pole_end| (the shallow counter-move)
/// qualifies when pullback < 0.5 · pole
/// bull flag : pole_end is a swing high → +1 (up-pole, continuation up)
/// bear flag : pole_end is a swing low → -1 (down-pole, continuation down)
/// ```
///
/// The detector fires on the bar that confirms the consolidation pivot (the flag
/// is complete; the breakout is expected to follow). Output is `+1.0` / `-1.0` /
/// `0.0`; never `None`.
#[derive(Debug, Clone)]
pub struct FlagPennant {
swing: SwingTracker,
has_emitted: bool,
}
impl FlagPennant {
/// Construct a new Flag / Pennant detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 3),
has_emitted: false,
}
}
}
impl Default for FlagPennant {
fn default() -> Self {
Self::new()
}
}
impl Indicator for FlagPennant {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 3 {
return Some(0.0);
}
let n = pivots.len();
let pole_start = pivots[n - 3];
let pole_end = pivots[n - 2];
let consolidation = pivots[n - 1];
let pole = (pole_end.price - pole_start.price).abs();
let pullback = (consolidation.price - pole_end.price).abs();
if pole > 0.0 && pullback < MAX_RETRACE_FRACTION * pole {
// pole_end a high → up-pole → bull flag; a low → bear flag.
return Some(if pole_end.direction > 0.0 { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// Three confirmed pivots; the earliest confirmation of the third is bar 4.
4
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"FlagPennant"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = FlagPennant::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = FlagPennant::new();
assert_eq!(indicator.name(), "FlagPennant");
assert_eq!(indicator.warmup_period(), 4);
assert!(!indicator.is_ready());
assert!(!FlagPennant::default().is_ready());
}
#[test]
fn bull_flag_is_plus_one() {
// Up-pole 100 → 140 (40), shallow pullback to 130 (10 < 20) → bull flag.
let out = run(&[150.0, 100.0, 140.0, 130.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn bear_flag_is_minus_one() {
// Down-pole 140 → 100 (40), shallow pullback to 110 (10 < 20) → bear flag.
let out = run(&[140.0, 100.0, 110.0]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn deep_pullback_is_not_a_flag() {
// Pole 100 → 140 (40) but pullback to 104 (36 > 20) → not a flag.
let out = run(&[150.0, 100.0, 140.0, 104.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = FlagPennant::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 130.0]);
let mut a = FlagPennant::new();
let mut b = FlagPennant::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,325 @@
//! GARCH(1,1) — conditional volatility with a long-run-variance anchor.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// GARCH(1,1) conditional volatility — the square root of the
/// generalized-autoregressive-conditional-heteroskedasticity variance recursion.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// σ²_t = ω + α · r²_{t1} + β · σ²_{t1}
/// out = √σ²_t
/// ```
///
/// GARCH(1,1) (Bollerslev 1986) generalizes the
/// [`EwmaVolatility`](crate::EwmaVolatility) recursion by adding a constant `ω`,
/// which pins the process to a finite long-run (unconditional) variance
/// `ω / (1 α β)`. The `α` term gives weight to the latest squared return
/// (the "ARCH" shock) and `β` to the previous variance (the "GARCH"
/// persistence). When `ω = 0` and `α + β = 1` the model degenerates to EWMA; a
/// proper GARCH keeps `ω > 0` and `α + β < 1` so volatility mean-reverts rather
/// than drifting.
///
/// The recursion is seeded with the unconditional variance (`σ²₁ = ω / (1 α
/// β)`) and emits from the first log return onward. Unlike EWMA — which decays to
/// zero on a flat series — a flat series here mean-reverts toward `ω / (1 β)`
/// (the `α`-term vanishes but the `ω` floor and the `β` carry remain), so the
/// output is always strictly positive. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Garch11, Indicator};
///
/// // Typical equity daily estimate.
/// let mut indicator = Garch11::new(0.000_002, 0.10, 0.88).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Garch11 {
omega: f64,
alpha: f64,
beta: f64,
unconditional: f64,
prev_price: Option<f64>,
/// `(σ²_{t1}, r²_{t1})` — previous variance and previous squared return.
state: Option<(f64, f64)>,
last: Option<f64>,
}
impl Garch11 {
/// Construct a new GARCH(1,1) indicator from its three parameters.
///
/// `omega` (`ω`) is the constant variance floor, `alpha` (`α`) the weight on
/// the latest squared return, and `beta` (`β`) the persistence of the
/// previous variance.
///
/// # Errors
/// Returns [`Error::InvalidParameter`] unless every parameter is finite,
/// `omega > 0`, `alpha >= 0`, `beta >= 0`, and `alpha + beta < 1` (the
/// covariance-stationarity condition that gives a finite long-run variance).
pub fn new(omega: f64, alpha: f64, beta: f64) -> Result<Self> {
if !omega.is_finite() || !alpha.is_finite() || !beta.is_finite() {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) parameters must be finite",
});
}
if omega <= 0.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) omega must be > 0",
});
}
if alpha < 0.0 || beta < 0.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) alpha and beta must be >= 0",
});
}
if alpha + beta >= 1.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) requires alpha + beta < 1 (covariance stationarity)",
});
}
Ok(Self {
omega,
alpha,
beta,
unconditional: omega / (1.0 - alpha - beta),
prev_price: None,
state: None,
last: None,
})
}
/// Configured `(omega, alpha, beta)`.
pub const fn params(&self) -> (f64, f64, f64) {
(self.omega, self.alpha, self.beta)
}
/// Long-run (unconditional) variance `ω / (1 α β)`.
pub const fn unconditional_variance(&self) -> f64 {
self.unconditional
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Garch11 {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the variance recursion.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
let r_sq = r * r;
let var = match self.state {
// Seed the recursion with the unconditional variance.
None => self.unconditional,
Some((prev_var, prev_r_sq)) => {
self.omega + self.alpha * prev_r_sq + self.beta * prev_var
}
};
self.state = Some((var, r_sq));
// `var` is `omega (> 0) + non-negative terms`, so it is strictly
// positive — the square root is always well-defined.
let vol = var.sqrt();
self.last = Some(vol);
Some(vol)
}
fn reset(&mut self) {
self.prev_price = None;
self.state = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price; the estimate is seeded
// with the unconditional variance and emitted on that first return.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Garch11"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
Garch11::new(0.0, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(-1.0, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, -0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, 0.1, -0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, 0.5, 0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(f64::NAN, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, f64::INFINITY, 0.8),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let g = Garch11::new(0.001, 0.1, 0.85).unwrap();
assert_eq!(g.params(), (0.001, 0.1, 0.85));
assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12);
assert_eq!(g.warmup_period(), 2);
assert_eq!(g.name(), "Garch11");
assert!(!g.is_ready());
assert_eq!(g.value(), None);
}
#[test]
fn first_emission_is_unconditional() {
// The first log return emits the seed = sqrt(unconditional variance),
// independent of the return value.
let g = Garch11::new(0.002, 0.1, 0.85);
let mut g = g.unwrap();
assert_eq!(g.update(100.0), None);
let out = g.update(110.0).unwrap();
assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12);
}
#[test]
fn known_value() {
// σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond.
let (omega, alpha, beta) = (0.002, 0.1, 0.85);
let mut g = Garch11::new(omega, alpha, beta).unwrap();
let out = g.batch(&[100.0, 110.0, 99.0]);
let uncond = omega / (1.0 - alpha - beta);
let r1 = (110.0_f64 / 100.0).ln();
assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12);
let var2 = omega + alpha * r1 * r1 + beta * uncond;
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
}
#[test]
fn flat_series_converges_to_long_run() {
// With zero returns the alpha term vanishes; the variance mean-reverts
// to the fixed point ω / (1 β), NOT to zero (the key GARCH/EWMA
// distinction).
let (omega, beta) = (0.002, 0.85);
let mut g = Garch11::new(omega, 0.10, beta).unwrap();
let out = g.batch(&[100.0; 400]);
let fixed_point = (omega / (1.0 - beta)).sqrt();
assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9);
}
#[test]
fn output_is_strictly_positive() {
let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in g.batch(&prices).into_iter().flatten() {
assert!(
v > 0.0,
"GARCH volatility must be strictly positive, got {v}"
);
}
}
#[test]
fn ignores_non_finite_input() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
let out = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(g.update(f64::NAN), last);
assert_eq!(g.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
let warmup = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(g.update(-5.0), Some(baseline));
assert_eq!(g.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = g.clone();
let after = g.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_price() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
assert_eq!(g.update(0.0), None);
assert_eq!(g.update(f64::NAN), None);
assert_eq!(g.update(100.0), None);
assert!(g.update(110.0).is_some());
}
#[test]
fn reset_clears_state() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.value(), None);
assert_eq!(g.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices);
let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,157 @@
//! Gartley harmonic pattern.
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gartley — the classic 5-point (X-A-B-C-D) harmonic pattern, recognised from
/// confirmed swing pivots when the legs fall inside the Gartley Fibonacci
/// windows:
///
/// ```text
/// AB / XA ∈ [0.55, 0.70] (≈ 0.618 retracement of XA)
/// BC / AB ∈ [0.382, 0.886]
/// CD / BC ∈ [1.13, 1.618]
/// AD / XA ∈ [0.74, 0.84] (≈ 0.786 — the defining D completion)
/// ```
///
/// Output is `+1.0` when the terminal point D is a swing low (bullish
/// completion), `-1.0` when D is a swing high (bearish), and `0.0` otherwise;
/// never `None`. See `crates/wickra-core/src/indicators/gartley.rs`.
#[derive(Debug, Clone)]
pub struct Gartley {
swing: SwingTracker,
has_emitted: bool,
}
impl Gartley {
/// Construct a new Gartley detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for Gartley {
fn default() -> Self {
Self::new()
}
}
impl Indicator for Gartley {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let p = xabcd(pivots);
let xa = (p.a - p.x).abs();
let ab = (p.b - p.a).abs();
let bc = (p.c - p.b).abs();
let cd = (p.d - p.c).abs();
let ad = (p.d - p.a).abs();
let matched = ratios_in(&[
(ab / xa, 0.55, 0.70),
(bc / ab, 0.382, 0.886),
(cd / bc, 1.13, 1.618),
(ad / xa, 0.74, 0.84),
]);
if matched {
return Some(if p.bullish { 1.0 } else { -1.0 });
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Gartley"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = Gartley::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = Gartley::new();
assert_eq!(indicator.name(), "Gartley");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!Gartley::default().is_ready());
}
#[test]
fn bullish_gartley_is_plus_one() {
let out = run(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]);
assert_eq!(*out.last().unwrap(), 1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn bearish_gartley_is_minus_one() {
let out = run(&[150.0, 110.0, 134.7, 122.35, 141.44]);
assert_eq!(*out.last().unwrap(), -1.0);
}
#[test]
fn out_of_ratio_does_not_trigger() {
// Five pivots but the D completion (AD/XA ≈ 0.25) is far from 0.786.
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = Gartley::new();
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]);
let mut a = Gartley::new();
let mut b = Gartley::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,205 @@
//! Bill Williams' Gator Oscillator (derived from the Alligator).
use crate::error::Result;
use crate::indicators::alligator::Alligator;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gator Oscillator output: the two histogram bars drawn above and below the
/// zero line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GatorOscillatorOutput {
/// Upper histogram `|jaw - teeth|`, always `>= 0`.
pub upper: f64,
/// Lower histogram `-|teeth - lips|`, always `<= 0`.
pub lower: f64,
}
/// Bill Williams' Gator Oscillator: a convergence/divergence view of the
/// [`Alligator`] lines. The upper bar is the absolute gap between Jaw and
/// Teeth; the lower bar is the negated absolute gap between Teeth and Lips.
///
/// ```text
/// upper = |jaw - teeth|
/// lower = -|teeth - lips |
/// ```
///
/// Widening bars mean the Alligator's mouth is opening (a trending market);
/// shrinking bars mean it is closing (consolidation). Warmup matches the
/// underlying Alligator — the first value appears once the slowest line (Jaw)
/// has warmed up.
///
/// Reference: Bill Williams, *Trading Chaos*, 1995.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, GatorOscillator, Indicator};
///
/// let mut indicator = GatorOscillator::classic();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GatorOscillator {
alligator: Alligator,
}
impl GatorOscillator {
/// Construct a Gator Oscillator from explicit Alligator periods
/// `(jaw, teeth, lips)`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if any period is zero.
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
Ok(Self {
alligator: Alligator::new(jaw_period, teeth_period, lips_period)?,
})
}
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
pub fn classic() -> Self {
Self {
alligator: Alligator::classic(),
}
}
/// Configured `(jaw_period, teeth_period, lips_period)`.
pub const fn periods(&self) -> (usize, usize, usize) {
self.alligator.periods()
}
}
impl Indicator for GatorOscillator {
type Input = Candle;
type Output = GatorOscillatorOutput;
fn update(&mut self, candle: Candle) -> Option<GatorOscillatorOutput> {
let lines = self.alligator.update(candle)?;
Some(GatorOscillatorOutput {
upper: (lines.jaw - lines.teeth).abs(),
lower: -(lines.teeth - lines.lips).abs(),
})
}
fn reset(&mut self) {
self.alligator.reset();
}
fn warmup_period(&self) -> usize {
self.alligator.warmup_period()
}
fn is_ready(&self) -> bool {
self.alligator.is_ready()
}
fn name(&self) -> &'static str {
"GatorOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, ts: i64) -> Candle {
let close = f64::midpoint(high, low);
Candle::new(close, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
GatorOscillator::new(0, 8, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
GatorOscillator::new(13, 0, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
GatorOscillator::new(13, 8, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let g = GatorOscillator::classic();
assert_eq!(g.periods(), (13, 8, 5));
assert_eq!(g.warmup_period(), 13);
assert_eq!(g.name(), "GatorOscillator");
assert!(!g.is_ready());
}
#[test]
fn constant_series_collapses_both_bars() {
// All three Alligator lines equal the constant median -> zero spread.
let mut g = GatorOscillator::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
let out = g.batch(&candles);
let last = out.last().unwrap().unwrap();
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn trending_series_opens_the_mouth() {
// On a clean trend the lines separate -> upper > 0, lower < 0.
let mut g = GatorOscillator::classic();
let candles: Vec<Candle> = (0_i64..80)
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
.collect();
let last = g.batch(&candles).last().unwrap().unwrap();
assert!(last.upper > 0.0, "upper {} should be positive", last.upper);
assert!(last.lower < 0.0, "lower {} should be negative", last.lower);
}
#[test]
fn warmup_emits_first_value_at_longest_period() {
let mut g = GatorOscillator::new(5, 3, 2).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
let out = g.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn reset_clears_state() {
let mut g = GatorOscillator::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
g.batch(&candles);
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
candle(base + 1.0, base - 1.0, i)
})
.collect();
let mut a = GatorOscillator::classic();
let mut b = GatorOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,222 @@
//! Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Generalized DEMA — the building block of Tillson's [`T3`](crate::T3),
/// exposed on its own.
///
/// ```text
/// GD = (1 + v) · EMA(price) v · EMA(EMA(price))
/// ```
///
/// where both EMAs share the same `period` and `v ∈ [0, 1]` is the *volume
/// factor*. `v` controls how much of the second-order lag correction is
/// applied:
///
/// - `v = 0` collapses GD to a plain [`Ema`](crate::Ema) (no correction).
/// - `v = 1` recovers the standard [`Dema`](crate::Dema) `2·EMA EMA(EMA)`.
/// - intermediate values (Tillson uses `0.7`) trade a little lag reduction for
/// less overshoot than DEMA.
///
/// Because the coefficients `(1 + v)` and `v` always sum to `1`, a constant
/// series maps to itself. The first output lands after `2·period 1` inputs —
/// EMA1 seeds at `period`, then EMA2 needs another `period 1` of EMA1's
/// outputs to seed, exactly like DEMA.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeneralizedDema};
///
/// let mut indicator = GeneralizedDema::new(5, 0.7).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeneralizedDema {
ema1: Ema,
ema2: Ema,
period: usize,
v: f64,
}
impl GeneralizedDema {
/// Construct a generalized DEMA with the given `period` and volume factor
/// `v`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`.
pub fn new(period: usize, v: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
return Err(Error::InvalidPeriod {
message: "GD volume factor must be a finite value in [0.0, 1.0]",
});
}
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
period,
v,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume factor `v`.
pub const fn volume_factor(&self) -> f64 {
self.v
}
}
impl Indicator for GeneralizedDema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
Some((1.0 + self.v) * e1 - self.v * e2)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
}
fn warmup_period(&self) -> usize {
// EMA1 seeds at period, then EMA2 needs another (period - 1) values.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.ema2.is_ready()
}
fn name(&self) -> &'static str {
"GD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::Dema;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
GeneralizedDema::new(0, 0.7),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_invalid_volume_factor() {
assert!(matches!(
GeneralizedDema::new(5, -0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
assert!(GeneralizedDema::new(5, 0.0).is_ok());
assert!(GeneralizedDema::new(5, 1.0).is_ok());
}
/// Cover the const accessors `period` + `volume_factor` and the
/// Indicator-impl `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let gd = GeneralizedDema::new(5, 0.7).unwrap();
assert_eq!(gd.period(), 5);
assert_relative_eq!(gd.volume_factor(), 0.7, epsilon = 1e-12);
// EMA1 seeds at 5, EMA2 needs another 4 -> 2*period - 1 = 9.
assert_eq!(gd.warmup_period(), 9);
assert_eq!(gd.name(), "GD");
}
#[test]
fn constant_series_yields_constant() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
let out = gd.batch(&[100.0_f64; 60]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 100.0, epsilon = 1e-9);
}
#[test]
fn v_one_equals_dema() {
// GD with v = 1 is exactly the standard DEMA.
let prices: Vec<f64> = (1..=80)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut gd = GeneralizedDema::new(7, 1.0).unwrap();
let mut dema = Dema::new(7).unwrap();
let gd_out = gd.batch(&prices);
let dema_out = dema.batch(&prices);
for (g, d) in gd_out.iter().zip(dema_out.iter()) {
assert_eq!(g.is_some(), d.is_some());
if let (Some(a), Some(b)) = (g, d) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn v_zero_equals_ema() {
// GD with v = 0 is a plain EMA (no second-order correction).
let prices: Vec<f64> = (1..=60).map(|i| f64::from(i) * 0.5).collect();
let mut gd = GeneralizedDema::new(6, 0.0).unwrap();
let mut ema = Ema::new(6).unwrap();
let gd_out = gd.batch(&prices);
for (i, (g, p)) in gd_out.iter().zip(prices.iter()).enumerate() {
// GD(v=0) feeds EMA1 into EMA2 but outputs EMA1 alone (coefficient
// 1 on e1, 0 on e2); it is only ready once EMA2 is, so compare
// against a standalone EMA chained the same way.
let want = ema.update(*p).filter(|_| i + 1 >= gd.warmup_period());
if let (Some(a), Some(b)) = (g, want) {
assert_relative_eq!(*a, b, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut a = GeneralizedDema::new(7, 0.7).unwrap();
let mut b = GeneralizedDema::new(7, 0.7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
gd.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(gd.is_ready());
gd.reset();
assert!(!gd.is_ready());
assert_eq!(gd.update(1.0), None);
}
}
@@ -0,0 +1,275 @@
//! Geometric Moving Average (GMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Geometric Moving Average — the rolling geometric mean of the last `period`
/// inputs.
///
/// ```text
/// GMA = (Π value_i)^(1/period) = exp( (1/period) · Σ ln(value_i) )
/// ```
///
/// The geometric mean is the natural average for *multiplicative* quantities
/// such as prices and growth factors: averaging in log-space weights relative
/// (percentage) moves symmetrically, so a `+10%` followed by a `10%` move
/// pulls the average below the start, exactly as compounded returns do. It is
/// always less than or equal to the arithmetic mean of the same window.
///
/// Maintained incrementally in O(1): the running sum of natural logs is updated
/// by adding the newcomer's log and subtracting the departing value's log as
/// the window slides.
///
/// The geometric mean is only defined for **strictly positive** inputs. A
/// non-finite or non-positive input is ignored (it leaves the window unchanged
/// and returns the current value), mirroring the non-finite handling of the
/// other moving averages.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeometricMa};
///
/// let mut indicator = GeometricMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeometricMa {
period: usize,
/// Natural logs of the values currently in the window (oldest at front).
logs: VecDeque<f64>,
sum_logs: f64,
}
impl GeometricMa {
/// Construct a new geometric moving average over `period` inputs.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
logs: VecDeque::with_capacity(period),
sum_logs: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.logs.len() == self.period {
Some((self.sum_logs / self.period as f64).exp())
} else {
None
}
}
}
impl Indicator for GeometricMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.value();
}
if self.logs.len() == self.period {
let oldest = self.logs.pop_front().expect("window non-empty");
self.sum_logs -= oldest;
}
let ln = input.ln();
self.logs.push_back(ln);
self.sum_logs += ln;
self.value()
}
fn reset(&mut self) {
self.logs.clear();
self.sum_logs = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.logs.len() == self.period
}
fn name(&self) -> &'static str {
"GMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Reference implementation: explicit geometric mean over a window.
fn gma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
prices
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < period {
None
} else {
let window = &prices[i + 1 - period..=i];
let product: f64 = window.iter().product();
Some(product.powf(1.0 / period as f64))
}
})
.collect()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(GeometricMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let gma = GeometricMa::new(7).unwrap();
assert_eq!(gma.period(), 7);
assert_eq!(gma.warmup_period(), 7);
assert_eq!(gma.name(), "GMA");
}
#[test]
fn warmup_returns_none() {
let mut gma = GeometricMa::new(3).unwrap();
assert_eq!(gma.update(1.0), None);
assert_eq!(gma.update(4.0), None);
// GMA(3) of [1, 4, 2] = (1·4·2)^(1/3) = 8^(1/3) = 2.
assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12);
}
#[test]
fn known_value_period_2() {
// GMA(2) of [4, 9] = sqrt(36) = 6.
let mut gma = GeometricMa::new(2).unwrap();
let v = gma.batch(&[4.0, 9.0]);
assert_relative_eq!(v[1].unwrap(), 6.0, epsilon = 1e-12);
}
#[test]
fn constant_series_returns_the_constant() {
let mut gma = GeometricMa::new(5).unwrap();
for v in gma.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
}
}
#[test]
fn period_one_is_pass_through() {
let mut gma = GeometricMa::new(1).unwrap();
assert_relative_eq!(gma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(gma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn below_or_equal_arithmetic_mean() {
// The geometric mean never exceeds the arithmetic mean of the same set.
let mut gma = GeometricMa::new(4).unwrap();
let prices = [10.0, 20.0, 5.0, 40.0];
let g = gma.batch(&prices)[3].unwrap();
let arithmetic = prices.iter().sum::<f64>() / 4.0;
assert!(
g < arithmetic,
"geometric {g} should be below arithmetic {arithmetic}"
);
}
#[test]
fn matches_naive_over_inputs() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 + 1.0).collect();
let mut gma = GeometricMa::new(7).unwrap();
let got = gma.batch(&prices);
let want = gma_naive(&prices, 7);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut gma = GeometricMa::new(4).unwrap();
gma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(gma.is_ready());
gma.reset();
assert!(!gma.is_ready());
assert_eq!(gma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5 + 1.0).collect();
let mut a = GeometricMa::new(5).unwrap();
let mut b = GeometricMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_and_non_positive_input() {
let mut gma = GeometricMa::new(3).unwrap();
gma.update(1.0);
gma.update(4.0);
let ready = gma.update(2.0).expect("GMA(3) ready after three inputs");
// Non-finite and non-positive inputs are skipped (geometric mean needs
// strictly positive values) and the window is left unchanged.
assert_eq!(gma.update(f64::NAN), Some(ready));
assert_eq!(gma.update(0.0), Some(ready));
assert_eq!(gma.update(-3.0), Some(ready));
// The window still holds 1, 4, 2 -> next real input slides it to 4, 2, 16.
let want = (4.0_f64 * 2.0 * 16.0).powf(1.0 / 3.0);
assert_relative_eq!(gma.update(16.0).unwrap(), want, epsilon = 1e-9);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn proptest_matches_naive(
period in 1usize..15,
prices in proptest::collection::vec(0.01_f64..1000.0, 0..120),
) {
let mut gma = GeometricMa::new(period).unwrap();
let got = gma.batch(&prices);
let want = gma_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() <= 1e-6 * b.abs().max(1.0),
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -0,0 +1,175 @@
//! Golden Pocket — the 0.618-0.65 optimal-trade-entry zone of the last swing.
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Lower bound of the golden pocket (the 61.8% retracement).
const RATIO_LOW: f64 = 0.618;
/// Upper bound of the golden pocket (the 65% retracement).
const RATIO_HIGH: f64 = 0.65;
/// The golden-pocket zone of the most recent swing leg.
///
/// `low`/`high` bracket the 0.618-0.65 retracement band (sorted, so `low <=
/// high` regardless of swing direction); `mid` is their midpoint.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GoldenPocketOutput {
/// Lower price of the golden-pocket band.
pub low: f64,
/// Midpoint of the band.
pub mid: f64,
/// Upper price of the golden-pocket band.
pub high: f64,
}
/// Golden Pocket (`GoldenPocket`).
///
/// The 0.618-0.65 retracement band of the most recent confirmed swing leg — the
/// "optimal trade entry" zone many swing traders watch for continuation.
///
/// Parameter-free; construction is infallible. Returns `None` until the first
/// leg is complete.
///
/// See `crates/wickra-core/src/indicators/golden_pocket.rs`.
#[derive(Debug, Clone)]
pub struct GoldenPocket {
swing: SwingTracker,
}
impl GoldenPocket {
/// Construct a new Golden Pocket tracker.
#[must_use]
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 2),
}
}
fn zone(&self) -> Option<GoldenPocketOutput> {
let pivots = self.swing.pivots();
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
let span = start - end;
let edge_low = end + RATIO_LOW * span;
let edge_high = end + RATIO_HIGH * span;
let low = edge_low.min(edge_high);
let high = edge_low.max(edge_high);
Some(GoldenPocketOutput {
low,
mid: f64::midpoint(low, high),
high,
})
}
}
impl Default for GoldenPocket {
fn default() -> Self {
Self::new()
}
}
impl Indicator for GoldenPocket {
type Input = Candle;
type Output = GoldenPocketOutput;
fn update(&mut self, candle: Candle) -> Option<GoldenPocketOutput> {
self.swing.update(candle);
self.zone()
}
fn reset(&mut self) {
self.swing.reset();
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.swing.pivots().len() >= 2
}
fn name(&self) -> &'static str {
"GoldenPocket"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn accessors_and_metadata() {
let indicator = GoldenPocket::new();
assert_eq!(indicator.name(), "GoldenPocket");
assert_eq!(indicator.warmup_period(), 2);
assert!(!indicator.is_ready());
assert!(!GoldenPocket::default().is_ready());
}
#[test]
fn no_output_before_two_pivots() {
let mut indicator = GoldenPocket::new();
let outputs: Vec<_> = candles_for_pivots(&[120.0])
.into_iter()
.map(|c| indicator.update(c))
.collect();
assert!(outputs.iter().all(Option::is_none));
}
#[test]
fn zone_of_a_down_leg() {
// Leg 200 (high) -> 100 (low), span = 100.
let mut indicator = GoldenPocket::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(indicator.is_ready());
// 61.8% = 161.8, 65% = 165 → sorted band [161.8, 165], mid 163.4.
assert_relative_eq!(v.low, 161.8);
assert_relative_eq!(v.high, 165.0);
assert_relative_eq!(v.mid, 163.4);
}
#[test]
fn band_is_sorted_for_an_up_leg() {
// Latest leg 100 (low) -> 250 (high): span negative, edges flip, but
// low <= high must still hold.
let mut indicator = GoldenPocket::new();
let mut last = None;
for candle in candles_for_pivots(&[200.0, 100.0, 250.0]) {
last = indicator.update(candle);
}
let v = last.unwrap();
assert!(v.low <= v.high);
assert_relative_eq!(v.mid, f64::midpoint(v.low, v.high));
}
#[test]
fn reset_clears_state() {
let mut indicator = GoldenPocket::new();
for candle in candles_for_pivots(&[200.0, 100.0]) {
let _ = indicator.update(candle);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert!(indicator.update(c).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
let mut a = GoldenPocket::new();
let mut b = GoldenPocket::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,191 @@
//! Head-and-Shoulders (and Inverse) reversal chart pattern.
use crate::indicators::pattern_swing::{
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Head-and-Shoulders / Inverse Head-and-Shoulders — a five-pivot reversal
/// pattern with a central extreme (the head) flanked by two lower/higher
/// shoulders at a similar level, joined by a roughly horizontal neckline.
///
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised on the
/// bar that confirms the right shoulder:
///
/// ```text
/// head-and-shoulders top (bearish, -1):
/// LeftShoulder(high) , Trough , Head(high) , Trough , RightShoulder(high)
/// Head > both shoulders ; LeftShoulder ≈ RightShoulder ; Trough₁ ≈ Trough₂
///
/// inverse head-and-shoulders (bullish, +1):
/// LeftShoulder(low) , Peak , Head(low) , Peak , RightShoulder(low)
/// Head < both shoulders ; LeftShoulder ≈ RightShoulder ; Peak₁ ≈ Peak₂
/// ```
///
/// The shoulders must match within [`LEVEL_TOLERANCE`] (3%) and the two neckline
/// points within the same tolerance. Output is `-1.0` for a top, `+1.0` for an
/// inverse, `0.0` otherwise; never `None`.
#[derive(Debug, Clone)]
pub struct HeadAndShoulders {
swing: SwingTracker,
has_emitted: bool,
}
impl HeadAndShoulders {
/// Construct a new Head-and-Shoulders detector.
pub const fn new() -> Self {
Self {
swing: SwingTracker::new(SWING_THRESHOLD, 5),
has_emitted: false,
}
}
}
impl Default for HeadAndShoulders {
fn default() -> Self {
Self::new()
}
}
impl Indicator for HeadAndShoulders {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
if !self.swing.update(candle) {
return Some(0.0);
}
let pivots = self.swing.pivots();
if pivots.len() < 5 {
return Some(0.0);
}
let n = pivots.len();
let left_shoulder = pivots[n - 5];
let neck_1 = pivots[n - 4];
let head = pivots[n - 3];
let neck_2 = pivots[n - 2];
let right_shoulder = pivots[n - 1];
let shoulders_match =
approx_equal(left_shoulder.price, right_shoulder.price, LEVEL_TOLERANCE);
let neckline_flat = approx_equal(neck_1.price, neck_2.price, LEVEL_TOLERANCE);
let head_is_peak = head.price > left_shoulder.price && head.price > right_shoulder.price;
let head_is_trough = head.price < left_shoulder.price && head.price < right_shoulder.price;
let frame_matches = shoulders_match && neckline_flat;
if right_shoulder.direction > 0.0 {
// Head-and-shoulders top: head is the highest of the three highs.
if head_is_peak && frame_matches {
return Some(-1.0);
}
} else if head_is_trough && frame_matches {
// Inverse: head is the lowest of the three lows.
return Some(1.0);
}
Some(0.0)
}
fn reset(&mut self) {
self.swing.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
// Five confirmed pivots; the earliest confirmation of the fifth is bar 6.
6
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"HeadAndShoulders"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::pattern_swing::candles_for_pivots;
use crate::traits::BatchExt;
fn run(pivots: &[f64]) -> Vec<f64> {
let mut indicator = HeadAndShoulders::new();
candles_for_pivots(pivots)
.into_iter()
.map(|c| indicator.update(c).unwrap())
.collect()
}
#[test]
fn accessors_and_metadata() {
let indicator = HeadAndShoulders::new();
assert_eq!(indicator.name(), "HeadAndShoulders");
assert_eq!(indicator.warmup_period(), 6);
assert!(!indicator.is_ready());
assert!(!HeadAndShoulders::default().is_ready());
}
#[test]
fn head_and_shoulders_top_is_minus_one() {
// LS 100, trough 90, head 120, trough 92, RS 101.
let out = run(&[100.0, 90.0, 120.0, 92.0, 101.0]);
assert_eq!(*out.last().unwrap(), -1.0);
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
}
#[test]
fn inverse_head_and_shoulders_is_plus_one() {
// Lead high then LS 100, peak 110, head 80, peak 108, RS 101.
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 101.0]);
assert_eq!(*out.last().unwrap(), 1.0);
}
#[test]
fn mismatched_shoulders_do_not_trigger() {
// Right shoulder (115) far from left (100) → no pattern.
let out = run(&[100.0, 90.0, 130.0, 92.0, 115.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn inverse_mismatched_shoulders_do_not_trigger() {
// Inverse shape (ends on a low) but the right shoulder (90) diverges from
// the left (100) → enters the inverse branch yet reports no pattern.
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 90.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn equal_highs_without_taller_head_do_not_trigger() {
// Three equal highs (no dominant head) → not H&S (that is a triple top).
let out = run(&[120.0, 90.0, 120.0, 92.0, 120.0]);
assert_eq!(*out.last().unwrap(), 0.0);
}
#[test]
fn reset_clears_state() {
let mut indicator = HeadAndShoulders::new();
for c in candles_for_pivots(&[100.0, 90.0, 120.0]) {
let _ = indicator.update(c);
}
indicator.reset();
assert!(!indicator.is_ready());
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
assert_eq!(indicator.update(c), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles = candles_for_pivots(&[100.0, 90.0, 120.0, 92.0, 101.0]);
let mut a = HeadAndShoulders::new();
let mut b = HeadAndShoulders::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}

Some files were not shown because too many files have changed in this diff Show More