E4: commit the documentation sources

The 33 Markdown files under docs/wiki/ were never tracked. Commit them
into the repository so the documentation is versioned alongside the
code: 8 top-level pages plus 25 per-indicator deep dives under
indicators/{momentum,trend,volatility,volume}/.

The pages are kept in-repo (not pushed to a flat GitHub Wiki), so the
relative indicators/<family>/... links in Home.md resolve correctly
when rendered on GitHub.
This commit is contained in:
kingchenc
2026-05-22 16:18:04 +02:00
parent 94cab88278
commit 278b6afaa4
33 changed files with 6635 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
# Wickra
Wickra is a streaming-first technical-indicators library. Every indicator is
implemented in Rust as an O(1) state machine that consumes one input at a
time, and the same engine is exposed through ergonomic bindings for Python,
Node.js, WebAssembly, and Rust itself. The same `update` call you write inside
a live trading loop also drives the historical backtest of that same
strategy — there is no second code path that drifts behind the streaming one.
The project ships 25 indicators across the four classical families (trend,
momentum, volatility, volume) and a small set of supporting types (`Candle`,
`Tick`, `Chain`). The Rust core forbids `unsafe`, so every binding inherits a
memory-safe implementation. Install is one command on every supported
platform: `pip install wickra`, `cargo add wickra`, `npm install wickra` — no
system compilers, no C dependencies, no headers.
Wickra is licensed under the **PolyForm Noncommercial 1.0.0** license.
Personal projects, research, hobby trading bots, education, non-profits, and
government use are all permitted; commercial sale of the software or of
services built around it is not. If you want to use Wickra commercially,
open an issue on GitHub to discuss a separate license.
## Published versions
| Registry | Package | Version |
|-----------|----------------|---------|
| crates.io | `wickra` | 0.1.4 |
| crates.io | `wickra-core` | 0.1.4 |
| crates.io | `wickra-data` | 0.1.4 |
| PyPI | `wickra` | 0.1.4 |
| npm | `wickra` | 0.1.4 |
| npm | `wickra-wasm` | 0.1.4 |
Release notes and tagged builds:
<https://github.com/kingchenc/wickra/releases>.
## Wiki contents
- [Quickstart: Python](Quickstart-Python.md) — `pip install wickra`, a batch
RSI on a NumPy array, a streaming RSI loop, and the multi-column NaN
pattern that MACD and friends share.
- [Quickstart: Rust](Quickstart-Rust.md) — `cargo add wickra`, batch and
streaming via the `Indicator` and `BatchExt` traits, and the `Chain`
combinator.
- [Quickstart: Node](Quickstart-Node.md) — `npm install wickra`, basic
`SMA` and `MACD` calls, and the current Windows install caveat
(`wickra-win32-x64-msvc@0.1.4` is held by the npm spam filter).
- [Streaming vs Batch](Streaming-vs-Batch.md) — the conceptual difference
between Wickra's O(1) `update` and the recompute-everything loops in
batch-only libraries, with the benchmark numbers from the project README.
- [Warmup Periods](Warmup-Periods.md) — a verified table of every
indicator's `warmup_period()`, plus the reasoning behind the off-by-one
cases (RSI(14) needs 15 inputs because it needs 14 diffs).
- [Indicator Chaining](Indicator-Chaining.md) — `Chain::new(first, second)`
and `.then(third)`, with a worked EMA(14) → RSI(7) example and the rule
for stacked warmups.
### Indicator reference
Start with [Indicators-Overview.md](Indicators-Overview.md) for the
cross-cutting taxonomy (trend / momentum / volatility / volume) and the
shared `Indicator` trait surface. The per-indicator pages below cover
formulas, parameters, warmup behaviour, edge cases, and verified
Rust / Python / Node examples. They are grouped by family, mirroring the
`indicators/<family>/` directory layout.
**Trend** — smooth the price series to surface direction.
- [Indicator-Sma.md](indicators/trend/Indicator-Sma.md)
- [Indicator-Ema.md](indicators/trend/Indicator-Ema.md)
- [Indicator-Wma.md](indicators/trend/Indicator-Wma.md)
- [Indicator-Dema.md](indicators/trend/Indicator-Dema.md)
- [Indicator-Tema.md](indicators/trend/Indicator-Tema.md)
- [Indicator-Hma.md](indicators/trend/Indicator-Hma.md)
- [Indicator-Kama.md](indicators/trend/Indicator-Kama.md)
**Momentum** — measure the rate of price change rather than the level.
- [Indicator-Rsi.md](indicators/momentum/Indicator-Rsi.md)
- [Indicator-MacdIndicator.md](indicators/momentum/Indicator-MacdIndicator.md)
- [Indicator-Stochastic.md](indicators/momentum/Indicator-Stochastic.md)
- [Indicator-Cci.md](indicators/momentum/Indicator-Cci.md)
- [Indicator-Roc.md](indicators/momentum/Indicator-Roc.md)
- [Indicator-WilliamsR.md](indicators/momentum/Indicator-WilliamsR.md)
- [Indicator-Adx.md](indicators/momentum/Indicator-Adx.md)
- [Indicator-Mfi.md](indicators/momentum/Indicator-Mfi.md)
- [Indicator-Trix.md](indicators/momentum/Indicator-Trix.md)
- [Indicator-AwesomeOscillator.md](indicators/momentum/Indicator-AwesomeOscillator.md)
- [Indicator-Aroon.md](indicators/momentum/Indicator-Aroon.md)
**Volatility** — envelope width and per-bar dispersion measures.
- [Indicator-BollingerBands.md](indicators/volatility/Indicator-BollingerBands.md)
- [Indicator-Atr.md](indicators/volatility/Indicator-Atr.md)
- [Indicator-Keltner.md](indicators/volatility/Indicator-Keltner.md)
- [Indicator-Donchian.md](indicators/volatility/Indicator-Donchian.md)
- [Indicator-Psar.md](indicators/volatility/Indicator-Psar.md)
**Volume** — price moves weighted or confirmed by traded volume.
- [Indicator-Obv.md](indicators/volume/Indicator-Obv.md)
- [Indicator-Vwap.md](indicators/volume/Indicator-Vwap.md)
## See also
- Source code: <https://github.com/kingchenc/wickra>
- Releases: <https://github.com/kingchenc/wickra/releases>
- Issue tracker: <https://github.com/kingchenc/wickra/issues>
+179
View File
@@ -0,0 +1,179 @@
# Indicator Chaining
`Chain<A, B>` wires the output of one indicator straight into the input of
another. Both stages must agree on `f64` as the bridging type, which is the
case for the vast majority of price-in / value-out indicators. The chain
itself is an `Indicator`, so chains can be nested arbitrarily and used
anywhere a single indicator is accepted.
This page documents the public API of `Chain` in
`crates/wickra-core/src/traits.rs`, the worked EMA(14) → RSI(7) example
that the doctest pins, and the warmup-stacking rule.
## Construction
```rust
use wickra::{Chain, Ema, Rsi};
// Two stages.
let chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
// Three stages — note the `.then(third)` builder method.
let triple = Chain::new(Ema::new(14)?, Ema::new(5)?).then(Rsi::new(7)?);
```
The type of `triple` is `Chain<Chain<Ema, Ema>, Rsi>`. You can keep chaining
indefinitely; the `Chain<A, B>` produced at each step also implements
`Indicator<Input = f64, Output = ...>`, which is the constraint
`.then(third)` needs to satisfy.
Both `first()` and `second()` accessors return references to the underlying
stages if you need to inspect them; the chain owns its stages by value.
## Worked example: EMA(14) → RSI(7)
This is the canonical chain example from the doctest on `Chain` in
`crates/wickra-core/src/traits.rs`:
```rust
use wickra::{Chain, Ema, Indicator, Rsi};
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
for i in 1..=21 {
chain.update(f64::from(i));
}
assert!(chain.is_ready());
```
The semantic shape of the chain is: smooth the input series with an
EMA(14), then compute an RSI(7) over the **smoothed** series — not the
raw inputs. `chain.update(price)` is the only thing your caller code ever
sees; the EMA-then-RSI plumbing is internal to the `Chain` value.
The chain emits its first non-`None` value at input **21**:
```rust
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
for i in 1..=22 {
if let Some(v) = chain.update(f64::from(i)) {
println!("chain emitted at input #{i}: {v}");
}
}
println!("chain.warmup_period() = {}", chain.warmup_period());
```
Output:
```
chain emitted at input #21: 100
chain emitted at input #22: 100
chain.warmup_period() = 22
```
(The value is `100` because the input is the monotonic ramp `1, 2, ..., 22`;
an RSI on a strictly increasing series is `100` by construction. The point
is the *timing* of the first emission.)
## Why first emission is at input 21, and `warmup_period` is 22
`Chain::warmup_period` is implemented conservatively:
```rust
fn warmup_period(&self) -> usize {
self.first.warmup_period() + self.second.warmup_period()
}
```
For `Chain::new(Ema::new(14)?, Rsi::new(7)?)` this expands to
`14 + 8 = 22` (RSI(7)'s warmup is `period + 1 = 8` — see
[Warmup Periods](Warmup-Periods.md) for the off-by-one detail).
In practice the chain emits one input earlier than that conservative sum
because the moment EMA(14) starts producing values is input 14, and RSI(7)
needs 8 EMA outputs to seed, so RSI(7) is ready on EMA output number 8,
which corresponds to input 14 + 7 = **21**. The conservative formula
`first.warmup + second.warmup` ignores this overlap; treat
`warmup_period()` as an upper bound and `is_ready()` as the source of truth
for "can I read a value yet":
```rust
if chain.is_ready() {
if let Some(v) = chain.update(price) {
// ...
}
}
```
## State, reset, and `Send`
`Chain<A, B>` propagates `reset()` to both stages:
```rust
fn reset(&mut self) {
self.first.reset();
self.second.reset();
}
```
So calling `chain.reset()` returns the whole pipeline to the state of a
freshly constructed `Chain::new(A::new(...), B::new(...))`. Because both
stages are owned by value and the trait `Indicator` is auto-derive-friendly,
the chain inherits `Clone`, `Debug`, and (where each stage is) `Send`.
The `batch` extension comes through `BatchExt` automatically — there's
nothing chain-specific to call:
```rust
use wickra::{BatchExt, Chain, Ema, Rsi};
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
let out: Vec<Option<f64>> = chain.batch(&prices);
```
## Stages from different families
The bridging type is `f64`, so anything `Indicator<Input = f64, Output = f64>`
can serve as the first stage and anything `Indicator<Input = f64>` can serve
as the second (or third, or fourth). Indicators that consume `Candle` /
`Tick` (`Atr`, `Adx`, `Stochastic`, `Mfi`, `Vwap`, `Psar`, `Keltner`,
`Donchian`, `Aroon`, `AwesomeOscillator`, `Obv`) cannot sit at the second
stage of a chain because their `Input` is not `f64`. They can still be used
as standalone indicators alongside a chain — wire them in your own loop.
A multi-output indicator like `MacdIndicator` or `BollingerBands` can sit
last in a chain (its `Output` is a struct, but the chain only requires
`Input = f64` on the **second** stage). For example,
`Chain::new(Sma::new(20)?, MacdIndicator::classic())` is a valid type:
MACD-of-smoothed-prices.
## Python and Node
`Chain` is currently a Rust-only construct; the Python and Node bindings
expose individual indicators only. The straightforward equivalent in those
languages is a manual two-step loop:
```python
import wickra as ta
ema = ta.EMA(14)
rsi = ta.RSI(7)
for price in prices:
smoothed = ema.update(price)
if smoothed is not None:
chained = rsi.update(smoothed)
if chained is not None:
...
```
This is exactly what `Chain::update` does in Rust, transcribed to the
binding's `update` method. No information is lost.
## See also
- [Quickstart: Rust](Quickstart-Rust.md) — the `Chain` example in context.
- [Warmup Periods](Warmup-Periods.md) — the underlying stage formulas, and
the RSI `period + 1` off-by-one.
- [Streaming vs Batch](Streaming-vs-Batch.md) — `Chain` works with both
paths automatically, via `BatchExt`.
- Source: <https://github.com/kingchenc/wickra>
+197
View File
@@ -0,0 +1,197 @@
# Indicators Overview
Wickra ships 25 indicators, organised in source under the four classical
families — trend, momentum, volatility, volume — that map directly to the
directory structure of `crates/wickra-core/src/indicators/`. The same family
labels are used here, plus a second-level grouping that reflects how the
indicators actually behave (which output range they live in, what data they
need, what question they answer).
Every indicator is an O(1) state machine that consumes one input at a time
and produces either `Option<f64>` (Rust), `float | None` (Python), or
`number | null` (Node). Inputs are either a `f64` close price or an OHLCV
`Candle` (Rust) / dict-or-tuple (Python) / column arrays (Node). The full
trait surface and warmup-period semantics are covered in
[Quickstart: Rust](Quickstart-Rust.md) and [Warmup Periods](Warmup-Periods.md).
The "Output range" column below is the value bounds an indicator emits once
warm. "unbounded" means it tracks the price scale of the input. The
"Warmup" column quotes `warmup_period()` as the indicator reports it; for
two indicators (`Hma`, `Kama`) the practical first-emission index can lag
the reported number because of stacked sub-indicator warmups — those
discrepancies are noted on the deep-dive pages.
## Trend
Trend indicators smooth the price series to surface direction. They are
all single-input, single-output (`f64 → f64`).
### Simple averages
Pure linear weighting. Mostly used as fast baselines or as comparison
benchmarks against fancier averages.
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive |
|-----------|-----------|-------|--------|-------|----------|--------|-----------|
| `Sma` | Equal-weighted rolling mean over `period` closes. | `f64` | `f64` | unbounded (price scale) | `period` (no default in core; Python defaults vary by binding) | `period` | [Indicator-Sma.md](indicators/trend/Indicator-Sma.md) |
| `Wma` | Linear weights `1, 2, …, period` so the newest bar matters most. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Wma.md](indicators/trend/Indicator-Wma.md) |
### Exponential family
Recursive smoothing with one or more chained EMAs. Lag reduction grows as
you stack more EMAs, but so does responsiveness to noise.
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive |
|-----------|-----------|-------|--------|-------|----------|--------|-----------|
| `Ema` | EMA with `α = 2 / (period + 1)`, seeded from the SMA of the first `period` inputs. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Ema.md](indicators/trend/Indicator-Ema.md) |
| `Dema` | Mulloy's `2·EMA EMA(EMA)`; removes first-order EMA lag. | `f64` | `f64` | unbounded (price scale) | `period` | `2·period 1` | [Indicator-Dema.md](indicators/trend/Indicator-Dema.md) |
| `Tema` | Mulloy's `3·EMA 3·EMA(EMA) + EMA(EMA(EMA))`; removes more lag than DEMA. | `f64` | `f64` | unbounded (price scale) | `period` | `3·period 2` | [Indicator-Tema.md](indicators/trend/Indicator-Tema.md) |
| `Trix` | `(EMA(EMA(EMA(price))).pct_change × 10000)`; oscillator built from a triple-smoothed EMA. | `f64` | `f64` | unbounded around zero | `period = 15` (Python) | `3·period 1` | (see source `crates/wickra-core/src/indicators/trix.rs`) |
### Adaptive & hybrid
These two adjust their effective smoothing on the fly. They are the
"smart" trend filters; both also live in Trend by directory placement.
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive |
|-----------|-----------|-------|--------|-------|----------|--------|-----------|
| `Hma` | Hull's `WMA(2·WMA(n/2) WMA(n), √n)`; near-zero lag with a built-in noise filter. | `f64` | `f64` | unbounded (price scale) | `period` | `period + round(√period) 1` (see notes) | [Indicator-Hma.md](indicators/trend/Indicator-Hma.md) |
| `Kama` | Kaufman's adaptive average: efficiency ratio picks an α between a fast and slow EMA per bar. | `f64` | `f64` | unbounded (price scale) | `(er_period=10, fast=2, slow=30)` | `er_period + 1` (see notes) | [Indicator-Kama.md](indicators/trend/Indicator-Kama.md) |
## Momentum
Momentum indicators measure the *rate* of price change, not the level.
Several are bounded by construction (0100 oscillators); others are
unbounded; one (`Adx`) is directional and bundles three values.
### Bounded oscillators (0 100)
These all share the "overbought above 70/80, oversold below 30/20"
mental model, though the exact thresholds differ in the literature.
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|--------------|-----------|-------|--------|-------|----------|--------|
| `Rsi` | Wilder's RSI; smoothed `gain / (gain + loss) × 100`. | `f64` | `f64` | `[0, 100]` | `period = 14` (Python) | `period + 1` |
| `Stochastic` | `%K = (close low_n)/(high_n low_n) × 100`, smoothed into `%D`. | `Candle` | `(k, d)` | each in `[0, 100]` | `(k_period=14, d_period=3)` (Python) | `k_period + d_period 1` |
| `Mfi` | "Volume-weighted RSI": Wilder smoothing of money-flow ratios. | `Candle` | `f64` | `[0, 100]` | `period = 14` (Python) | `period` |
| `Aroon` | Bars-since-high and bars-since-low scaled to `[0, 100]`. | `Candle` | `(up, down)` | each in `[0, 100]` | `period = 14` (Python) | `period + 1` |
### Unbounded oscillators
Centered on zero or driven by raw price differences; no fixed cap.
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|---------------------|-----------|-------|--------|-------|----------|--------|
| `MacdIndicator` | `EMA(fast) EMA(slow)` plus a signal-line EMA and the difference histogram. | `f64` | `(macd, signal, histogram)` | unbounded around zero | `(fast=12, slow=26, signal=9)` (Python) | `slow + signal 1` |
| `Cci` | `(typical SMA(typical)) / (0.015 · mean_dev)`; unbounded but typically `±100`. | `Candle` | `f64` | unbounded (typically `±100` to `±200`) | `period = 20` (Python) | `period` |
| `Roc` | `(price price_n) / price_n × 100`; raw percentage change over `period` bars. | `f64` | `f64` | unbounded around zero | `period` | `period + 1` |
| `AwesomeOscillator` | `SMA(median, fast) SMA(median, slow)`; Bill Williams' zero-line crossover oscillator. | `Candle` | `f64` | unbounded around zero | `(fast=5, slow=34)` (Python) | `slow_period` |
| `WilliamsR` | `100 × (high_n close) / (high_n low_n)`; same family as Stochastic but inverted to `[100, 0]`. | `Candle` | `f64` | `[100, 0]` | `period = 14` (Python) | `period` |
### Directional
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|-----------|-----------|-------|--------|-------|----------|--------|
| `Adx` | Wilder's directional system: `+DI`, `DI` (each `[0, 100]`) and `ADX` trend-strength index. | `Candle` | `(plus_di, minus_di, adx)` | each in `[0, 100]` | `period = 14` (Python) | `2·period` |
## Volatility
Volatility indicators sit in two functional groups: those that draw an
envelope around price, and those that report a scalar dispersion/range.
PSAR is a special case — a trailing-stop tracker rather than a width
measure — that lives in the volatility module by source convention.
### Envelopes
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|-------------------|-----------|-------|--------|-------|----------|--------|
| `BollingerBands` | SMA middle band with `±multiplier × population_stddev` upper/lower bands. | `f64` | `(upper, middle, lower, stddev)` | unbounded (price scale) | `(period=20, multiplier=2.0)` (Python) | `period` |
| `Keltner` | EMA middle band with `±multiplier × ATR` upper/lower bands. | `Candle` | `(upper, middle, lower)` | unbounded (price scale) | `(ema_period=20, atr_period=10, multiplier=2.0)` (Python) | `max(ema_period, atr_period)` |
| `Donchian` | Highest high and lowest low over `period` bars; middle = mean of the two. | `Candle` | `(upper, middle, lower)` | unbounded (price scale) | `period = 20` (Python) | `period` |
### Range-average
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|-----------|-----------|-------|--------|-------|----------|--------|
| `Atr` | Wilder-smoothed True Range; per-bar absolute volatility. | `Candle` | `f64` | `[0, ∞)` (price scale) | `period = 14` (Python) | `period` |
### Trailing stop
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|-----------|-----------|-------|--------|-------|----------|--------|
| `Psar` | Wilder's Parabolic Stop-and-Reverse; per-bar stop level that flips sides on price crossing. | `Candle` | `f64` | unbounded (price scale) | `(af_start=0.02, af_step=0.02, af_max=0.20)` (Python) | `2` |
## Volume
Volume indicators all take `Candle` input because they need `close` and
`volume` together (some also need `high`/`low`).
### Cumulative
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|---------------|-----------|-------|--------|-------|----------|--------|
| `Obv` | On-Balance Volume: cumulative signed volume driven by close-vs-prior-close sign. | `Candle` | `f64` | unbounded (drifts with cumulative volume) | (no parameters) | `1` |
| `Vwap` | Cumulative volume-weighted average price from the start of the stream (intraday reset is your responsibility). | `Candle` | `f64` | unbounded (price scale) | (no parameters) | `1` |
### Rolling
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup |
|---------------|-----------|-------|--------|-------|----------|--------|
| `RollingVwap` | VWAP over a sliding window instead of since-start; useful for session-independent VWAP. | `Candle` | `f64` | unbounded (price scale) | `period` | `period` |
## Pick the right indicator for…
A short cheat-sheet of "I want X, which indicator?" answers, grounded in
what each indicator actually computes.
- **Fast trend filter, minimal lag, single line.** `Hma` for smoothness +
responsiveness, `Tema` for further lag reduction at the cost of more
noise. If you want adaptiveness instead of fixed lag, `Kama`.
- **Slow trend filter, smooth as glass.** `Sma` is the simplest; `Ema`
responds slightly faster with the same smoothness budget. For long
trend filters either is appropriate; the difference is mostly aesthetic.
- **Trend-following crossovers.** Two-line crossovers (`Ema(fast)` vs
`Ema(slow)`, or any of the trend pairs) are the textbook entry signal;
`MacdIndicator` packages the same idea with a signal line and histogram.
- **Trend strength (is there a trend at all?).** `Adx` is the canonical
answer: `adx > 25` is "trending", `adx < 20` is "ranging". `Aroon` is
a softer alternative when you want directional confirmation.
- **Overbought / oversold reversal candidate.** `Rsi` is the default;
`Stochastic` for faster signals; `WilliamsR` for the same logic with an
inverted scale; `Mfi` if you have volume and want a volume-aware RSI.
- **Volatility expansion / contraction.** `BollingerBands` width
(`upper lower`) for relative volatility; `Atr` for absolute per-bar
volatility in price units; `Keltner` to compare price against an
ATR-scaled envelope.
- **Breakout level.** `Donchian` upper/lower bands are the textbook
Turtle-style breakout trigger.
- **Trailing stop.** `Psar` gives you a per-bar stop level that flips
sides as the trend reverses. `Atr · k` (compute `Atr` yourself, multiply
by your preferred `k`) is the common alternative.
- **Volume confirmation.** `Obv` is the simplest; `Mfi` adds price into
the equation; `Vwap` / `RollingVwap` give you the volume-weighted
reference price.
- **Bill Williams setups.** `AwesomeOscillator` for the zero-line cross /
twin-peaks pattern from his suite.
- **Rate-of-change scalar.** `Roc` is the unsmoothed percentage change;
`Trix` is the same idea but on a triple-smoothed EMA.
## Source-of-truth files
Every claim above can be checked against the source in
`D:\Coding\Wickra\crates\wickra-core\src\indicators\` — one file per
indicator. The Rust unit tests inside each module are the ground truth
for sample values. Python defaults (the `period = 14` etc. in tables
above) come from the `#[pyo3(signature = …)]` attributes in
`D:\Coding\Wickra\bindings\python\src\lib.rs`; indicators not listed
with a Python default require an explicit `period` argument.
## See also
- [Warmup Periods](Warmup-Periods.md) — full verified table of every
indicator's `warmup_period()`.
- [Indicator Chaining](Indicator-Chaining.md) — combining indicators with
`Chain` and the stacked-warmup rule.
- [Quickstart: Rust](Quickstart-Rust.md), [Quickstart: Python](Quickstart-Python.md),
[Quickstart: Node](Quickstart-Node.md) — language-specific API surfaces.
- Source: <https://github.com/kingchenc/wickra>
+173
View File
@@ -0,0 +1,173 @@
# Quickstart: Node
A five-minute tour of the Wickra Node.js binding. The binding is generated by
[napi-rs](https://napi.rs/), so it's a native addon — no WebAssembly, no
slow JS reimplementation.
## Install
```bash
npm install wickra
```
> **Windows install caveat (current, 0.1.4).** The platform-specific
> sub-package `wickra-win32-x64-msvc@0.1.4` is presently held back by npm's
> automated spam filter. On a Windows x64 machine `npm install wickra`
> succeeds, but `require('wickra')` then throws
> `Error: Cannot find module 'wickra-win32-x64-msvc'` because the loader
> falls through to `require('wickra-win32-x64-msvc')` when no local `.node`
> binary is found. Linux x64 and macOS (x64 + arm64) wheels are unaffected.
> If you are on Windows today, build the binding from source (see "Building
> from source" below) until the npm side is resolved.
## A first run
```javascript
const wickra = require('wickra');
console.log('wickra', wickra.version());
// Simple moving average over a fixed window.
const sma = new wickra.SMA(3);
console.log(sma.batch([2, 4, 6, 8, 10]));
// -> [ NaN, NaN, 4, 6, 8 ]
```
Two things to notice:
1. The `batch` return type is a regular JavaScript `Array<number>`. Warmup
slots are `NaN`, not `null` or `undefined`, so the array is
`Number.isFinite`-checkable in one pass.
2. `new wickra.SMA(0)` does **not** throw. Constructors in the Node binding
currently cannot raise errors from JS (a napi-rs 2.16 limitation), so
pathological values like `period = 0` are clamped to the smallest valid
window. This is exactly the behaviour pinned by
`bindings/node/__tests__/smoke.test.js` ("zero period is clamped to a
valid window").
## Streaming
```javascript
const wickra = require('wickra');
const sma = new wickra.SMA(3);
for (const price of [2, 4, 6, 8, 10]) {
const value = sma.update(price);
console.log('update', price, '->', value);
}
```
Output:
```
update 2 -> null
update 4 -> null
update 6 -> 4
update 8 -> 6
update 10 -> 8
```
`update` returns either a JavaScript `number` or `null` while the indicator
is still warming up. (Compare with `batch`, where warmup slots are `NaN`.
The asymmetry exists because the streaming API surfaces "no value yet"
through the JS type system, whereas the batch result is shaped as a numeric
array for downstream numeric code.)
## MACD: streaming and batch
`MACD` is the canonical multi-output indicator. The Node API surfaces this in
two shapes:
- `update(price)` returns either `null` (during warmup) or
`{ macd, signal, histogram }`.
- `batch(prices)` returns a **flat** `Array<number>` of length `prices.length * 3`,
laid out as `[macd_0, signal_0, hist_0, macd_1, signal_1, hist_1, ...]`,
with each warmup row written as three `NaN`s.
Streaming form:
```javascript
const wickra = require('wickra');
const macd = new wickra.MACD(12, 26, 9);
let last = null;
for (let i = 0; i < 40; i++) {
last = macd.update(100 + i * 0.5);
}
console.log(last);
// -> { macd: 3.5, signal: 3.500000000000001, histogram: -8.881784197001252e-16 }
```
Batch form (note the flat layout):
```javascript
const wickra = require('wickra');
const prices = Array.from({ length: 40 }, (_, i) => 100 + i * 0.5);
const macd = new wickra.MACD(12, 26, 9);
const flat = macd.batch(prices);
console.log('total values:', flat.length); // -> 120 (= 40 * 3)
console.log('row 33 :', flat.slice(99, 102)); // -> [ 3.5, 3.5, 0 ]
console.log('row 39 :', flat.slice(117, 120)); // -> [ 3.5, 3.5000000..., -8.88e-16 ]
// Reshape into 3-tuples if you want:
const rows = [];
for (let i = 0; i < flat.length; i += 3) {
rows.push({ macd: flat[i], signal: flat[i + 1], histogram: flat[i + 2] });
}
```
`MACD(12, 26, 9)` emits its first non-NaN row at index 33 because the
underlying Rust `warmup_period()` is `slow + signal 1 = 34` (the first
ready row is `warmup_period - 1` in 0-indexed terms).
## API surface
The complete TypeScript definitions live at
`bindings/node/index.d.ts`. Every indicator class exposes some subset of:
| Member | Notes |
|---------------------------|------------------------------------------------------------------------|
| `constructor(...)` | Pathological values are clamped, not thrown. |
| `update(...)` | Returns the indicator output or `null` during warmup. |
| `batch(...)` | Single-output: flat `Array<number>` with `NaN` warmup.<br>Multi-output: flat interleaved `Array<number>`. |
| `reset()` | Returns to a freshly-constructed state. |
| `isReady()` | `true` once the first value has been emitted. |
| `warmupPeriod()` | Present on single-output indicators (SMA, EMA, WMA, RSI, ...). Not exposed on every multi-output class — check `index.d.ts`. |
A complete reference run lives in `bindings/node/__tests__/smoke.test.js`:
```bash
cd bindings/node
npm install
npm run build # only needed if you cloned the repo (Windows: see above)
npm test
```
## Building from source (Windows workaround)
If you are on Windows x64 and want to use Wickra today, build the binding
locally from the repository:
```bash
git clone https://github.com/kingchenc/wickra
cd wickra/bindings/node
npm install
npm run build # requires a Rust toolchain (rustup) on PATH
npm test
```
`npm run build` produces `wickra.win32-x64-msvc.node` in the binding
directory; the platform loader in `index.js` then picks it up before falling
through to the npm sub-package and the install just works.
## See also
- [Quickstart: Python](Quickstart-Python.md) — sibling binding with NumPy
shapes.
- [Streaming vs Batch](Streaming-vs-Batch.md) — why `update` is the primary
entry point, not `batch`.
- [Warmup Periods](Warmup-Periods.md) — exact `warmup_period()` for every
indicator.
- Source: <https://github.com/kingchenc/wickra>
+181
View File
@@ -0,0 +1,181 @@
# Quickstart: Python
A five-minute tour of the Wickra Python binding. By the end you will have run
a batch RSI over a NumPy array, fed the same indicator one tick at a time,
and read a multi-column MACD result correctly during warmup.
## Install
```bash
pip install wickra
```
The published wheels target Python 3.9 3.12 on Linux x86_64, macOS
(Intel + Apple Silicon), and Windows x86_64. The only runtime dependency is
`numpy >= 1.22`. No system compiler, no C headers, no Rust toolchain are
needed to install — Wickra ships pre-built native wheels.
Verify the install:
```python
import wickra as ta
print(ta.__version__)
```
## Batch: RSI over a NumPy array
`Indicator.batch(prices)` takes a 1-D `numpy.ndarray` of `float64` closes and
returns a 1-D `numpy.ndarray` of `float64` outputs. Warmup steps come back as
`NaN` so the result aligns 1:1 with your input prices and slots straight into
a pandas column or a NumPy mask.
The first 15 prices below are the classic Wilder textbook example. RSI(14)
emits its first value at index 14 (the 15th input) because it needs 14
diffs to seed Wilder's smoothing.
```python
import numpy as np
import wickra as ta
prices = np.array([
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42,
45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00,
46.03, 46.41, 46.22, 45.64,
], dtype=float)
rsi = ta.RSI(14)
values = rsi.batch(prices)
print(values.dtype, values.shape)
print("warmup count:", int(np.isnan(values).sum()))
print("first value :", float(values[14]))
print("last value :", float(values[-1]))
```
Running this prints:
```
float64 (20,)
warmup count: 14
first value : 70.46413502109705
last value : 57.91502067008556
```
The exact first value `70.464` matches Wilder's published table; this is the
same input/output pair the Rust test suite pins as `classic_wilder_textbook_values`
in `crates/wickra-core/src/indicators/rsi.rs`.
## Streaming: feed one price at a time
The same `RSI` instance can be driven tick-by-tick with `update()`. Each call
is O(1) and returns either a `float` or `None` while the indicator is still
warming up.
```python
import wickra as ta
rsi = ta.RSI(14)
prices = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42,
45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00,
46.03, 46.41,
]
for tick, price in enumerate(prices, start=1):
value = rsi.update(price)
if value is not None:
print(f"tick {tick:2d} close={price:.2f} rsi={value:.4f}")
```
Output:
```
tick 15 close=46.28 rsi=70.4641
tick 16 close=46.00 rsi=66.2496
tick 17 close=46.03 rsi=66.4809
tick 18 close=46.41 rsi=69.3469
```
Tick 15 is the first emission because `RSI(14).warmup_period() == 15`. Before
that, `update()` returns `None`. After warmup the indicator never goes back
to `None`: each subsequent tick produces a steady value.
The full set of streaming-state methods is:
| Method | Returns | Notes |
|-----------------------|----------------|--------------------------------------------|
| `update(price)` | `float`/`None` | O(1) state transition, `None` during warmup |
| `batch(prices)` | `np.ndarray` | replays `update`, `NaN` during warmup |
| `reset()` | `None` | returns to a freshly-constructed state |
| `is_ready()` | `bool` | `True` once the first value has been emitted |
| `warmup_period()` | `int` | inputs required before the first value |
## MACD: a multi-column indicator and its warmup NaNs
Some indicators emit several values at once. `MACD` returns three: the MACD
line, the signal line, and the histogram. The Python `batch` reflects that
shape directly — instead of a 1-D `float64` vector you get a 2-D
`(n_rows, n_columns)` array, and each warmup row is filled with `NaN` across
every column. (`Stochastic` follows the same pattern with two columns,
`Bollinger Bands` with four, `Keltner`/`Donchian`/`ADX` with three.)
```python
import numpy as np
import wickra as ta
prices = np.linspace(100.0, 120.0, 40)
macd = ta.MACD(12, 26, 9)
out = macd.batch(prices)
print("shape :", out.shape)
print("warmup rows :", int(np.isnan(out[:, 0]).sum()))
print("first ready :", int(np.argmax(~np.isnan(out[:, 0]))))
print("row 33 :", out[33])
print("row 39 :", out[39])
```
Output:
```
shape : (40, 3)
warmup rows : 33
first ready : 33
row 33 : [ 3.58974359e+00 3.58974359e+00 -1.77635684e-15]
row 39 : [3.58974359e+00 3.58974359e+00 6.21724894e-15]
```
Two things to notice:
1. `MACD(12, 26, 9).warmup_period()` is `slow + signal - 1 = 34`, and indeed
row `34 - 1 = 33` is the first row where every column is finite. Earlier
rows are entirely `NaN`; you should not slice a partial row out and use,
say, the `signal` column independently of the `macd` column.
2. Columns are positional — `out[:, 0]` is MACD, `out[:, 1]` is signal,
`out[:, 2]` is histogram. The streaming form returns the same triple as a
plain Python tuple: `(macd, signal, histogram)`.
To filter a warmup-aware mask cleanly:
```python
ready = ~np.isnan(out[:, 0])
clean_rows = out[ready]
```
`ready` is a single boolean column you can apply to every column at once
because the warmup pattern is identical across all of them.
## A deeper example
`examples/python/backtest.py` in the repo runs a full panel of indicators
(RSI, EMA, Bollinger, MACD, ATR, ADX, OBV) over an OHLCV CSV and prints a
summary. It's a good template for "I have historical data on disk, give me a
table of indicator values" workflows; for live workflows, see
`examples/python/live_trading.py`.
## See also
- [Quickstart: Rust](Quickstart-Rust.md) — same API surface in Rust.
- [Streaming vs Batch](Streaming-vs-Batch.md) — why the streaming path is
the primary one, not a convenience.
- [Warmup Periods](Warmup-Periods.md) — the full table of warmup counts.
- Source: <https://github.com/kingchenc/wickra>
+142
View File
@@ -0,0 +1,142 @@
# Quickstart: Rust
A five-minute tour of the Wickra Rust crate. By the end you will have run a
batch SMA, fed an RSI tick by tick, and composed two indicators with `Chain`.
## Install
```bash
cargo add wickra
```
The default features pull in `parallel` (rayon-based `batch_parallel`); turn
them off with `cargo add wickra --no-default-features` if you want a leaner
build. The `wickra` crate is a thin façade that re-exports everything from
`wickra-core`; you can also depend on `wickra-core` directly if you want to
skip the façade.
The published crate is at version `0.1.4` on
[crates.io](https://crates.io/crates/wickra).
## The `Indicator` trait in 30 seconds
Every indicator implements the same trait:
```rust
pub trait Indicator {
type Input;
type Output;
fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
fn reset(&mut self);
fn warmup_period(&self) -> usize;
fn is_ready(&self) -> bool;
fn name(&self) -> &'static str;
}
```
`update` is O(1) in the input length. The companion trait `BatchExt` is a
blanket extension that adds a `batch(&[Self::Input])` method to every
indicator — its default implementation is literally a loop over `update`, so
batch and streaming results are bit-for-bit identical.
## Batch and streaming side by side
```rust
use wickra::{BatchExt, Indicator, Rsi, Sma};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Batch: SMA(3) over five prices.
let mut sma = Sma::new(3)?;
let out: Vec<Option<f64>> = sma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
println!("{:?}", out);
// -> [None, None, Some(2.0), Some(3.0), Some(4.0)]
// 2. Streaming: feed Wilder's textbook example into RSI(14).
let mut rsi = Rsi::new(14)?;
let prices = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08,
45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41,
];
for (tick, price) in prices.iter().enumerate() {
if let Some(v) = rsi.update(*price) {
println!("tick {:2} close={:.2} rsi={:.4}", tick + 1, price, v);
}
}
Ok(())
}
```
The streaming loop prints:
```
tick 15 close=46.28 rsi=70.4641
tick 16 close=46.00 rsi=66.2496
tick 17 close=46.03 rsi=66.4809
tick 18 close=46.41 rsi=69.3469
```
The first value lands on tick 15 because `Rsi::new(14)?.warmup_period() == 15`
(14 diffs to seed Wilder's smoothing, so the 15th input emits the first RSI).
The `70.4641` value matches the textbook value pinned by the unit test
`classic_wilder_textbook_values` in `crates/wickra-core/src/indicators/rsi.rs`.
## Composing indicators with `Chain`
`Chain<A, B>` wires the output of `A` straight into the input of `B`, provided
both stages agree on `f64` as the bridging type. The chain itself is an
`Indicator`, so you can stack three stages with `.then(c)`, or four with
`.then(c).then(d)`.
```rust
use wickra::{Chain, Ema, Indicator, Rsi};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// RSI(7) computed on the output of EMA(14).
let mut chain = Chain::new(Ema::new(14)?, Rsi::new(7)?);
for i in 1..=22 {
if let Some(v) = chain.update(f64::from(i)) {
println!("chain emitted at input #{i}: {v}");
}
}
println!("chain.warmup_period() = {}", chain.warmup_period());
Ok(())
}
```
Output:
```
chain emitted at input #21: 100
chain emitted at input #22: 100
chain.warmup_period() = 22
```
`Ema::new(14)` needs 14 inputs to seed and `Rsi::new(7)` needs 8 more once
the EMA starts flowing, so the chain emits its first value at input 21. The
`warmup_period()` reported by `Chain` is a conservative `first + second` sum
(here `14 + 8 = 22`); see [Indicator Chaining](Indicator-Chaining.md) for the
exact contract.
## A deeper example
`crates/wickra/examples/backtest.rs` shipped with the workspace computes a
panel of indicators (RSI, EMA, Bollinger, MACD, ATR, ADX, OBV) over an OHLCV
CSV by way of `wickra-data`:
```bash
cargo run --release --example backtest -- path/to/ohlcv.csv
```
For live-data work, `wickra-data` ships a streaming CSV reader, a
tick-to-candle aggregator, a candle resampler, and a Binance kline WebSocket
adapter under the `live-binance` feature. `crates/wickra-data/examples/live_binance.rs`
is the canonical example for the latter.
## See also
- [Quickstart: Python](Quickstart-Python.md) — same engine, NumPy-flavoured.
- [Streaming vs Batch](Streaming-vs-Batch.md) — the `batch == repeated update`
contract and the benchmark numbers it buys you.
- [Indicator Chaining](Indicator-Chaining.md) — three-stage chains and the
stacked-warmup rule.
- Source: <https://github.com/kingchenc/wickra>
+160
View File
@@ -0,0 +1,160 @@
# Streaming vs Batch
Wickra has one engine, not two. Every indicator is a state machine driven by
a single method, `Indicator::update`, and the batch API is a thin loop over
that method. This page is the concept doc for why that matters, and what
contracts you can rely on when you mix the two in real code.
## The `update` contract
`Indicator::update` is the only state transition. From `crates/wickra-core/src/traits.rs`:
```rust
pub trait Indicator {
type Input;
type Output;
/// Feed one new data point into the indicator and return the freshly computed
/// output, or `None` if the indicator is still warming up.
fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
fn reset(&mut self);
fn warmup_period(&self) -> usize;
fn is_ready(&self) -> bool;
fn name(&self) -> &'static str;
}
```
Three properties hold by contract:
1. **O(1) in the input length.** `update` may touch some pre-existing
buffered state, but it must never recompute over the entire history. The
`wickra-core` crate is `#![forbid(unsafe_code)]`, and the standard
indicator implementations all carry rolling sums, single recursive
accumulators, or fixed-size `VecDeque` windows.
2. **`None` during warmup, `Some` thereafter.** An indicator returns `None`
while it doesn't yet have enough data to produce a defined value. After
the first `Some`, it never goes back to `None` (short of a `reset()`).
3. **`reset()` restores construction-time state.** The state-machine is
fully encapsulated, so resetting and replaying produces bit-identical
results to a fresh instance.
## The `BatchExt` blanket implementation
The batch API is a blanket extension on top of every `Indicator`. The whole
implementation is six lines:
```rust
pub trait BatchExt: Indicator {
fn batch(&mut self, inputs: &[Self::Input]) -> Vec<Option<Self::Output>>
where Self::Input: Clone,
{
let mut out = Vec::with_capacity(inputs.len());
for x in inputs {
out.push(self.update(x.clone()));
}
out
}
}
impl<T: Indicator> BatchExt for T {}
```
Two consequences:
- **`batch == repeated update`, exactly.** There is no separate "vectorised"
code path that might disagree numerically with the streaming one. A unit
test pinning this invariant — `batch_equals_streaming` — lives in nearly
every `crates/wickra-core/src/indicators/<name>.rs` file. You can rely on
the batch results in your backtest matching the streaming results that
your live bot will see.
- **Implementing one trait is enough.** Adding a new indicator means
implementing `Indicator` in Rust; every binding plus every batch helper
comes along for free.
You can verify the equivalence yourself in Python:
```python
import numpy as np
import wickra as ta
np.random.seed(0)
prices = np.cumsum(np.random.randn(100)) + 100.0
# Batch path.
batch_out = ta.RSI(14).batch(prices)
# Streaming path: same inputs, fresh indicator, fed one at a time.
rsi = ta.RSI(14)
stream_out = np.array(
[np.nan if (v := rsi.update(p)) is None else v for p in prices]
)
b_nan = np.isnan(batch_out)
s_nan = np.isnan(stream_out)
assert np.array_equal(b_nan, s_nan)
assert np.array_equal(batch_out[~b_nan], stream_out[~s_nan])
```
This passes; the last three values of both arrays are
`[69.64533252, 70.00767057, 71.18111330]`.
## Why batch-only libraries fall behind live
Suppose a strategy looks at RSI(14) on each new minute-bar of a market. A
classical batch-only library (TA-Lib, pandas-ta, finta, ...) gives you a
single function `rsi(prices)` that recomputes the indicator over the entire
input array. To use it inside a streaming loop, you concatenate each new
tick onto your history and call `rsi(history)` again. That's
`O(n)` work for every new bar, and the gap widens linearly as `n` grows.
Wickra's `update` is the opposite: each new bar is O(1) because the
recursive smoothing state is already inside the indicator. You never carry
history just to recompute it.
The numbers below are reproduced from the project README, where
`python -m benchmarks.compare_libraries` is the source script.
### Batch — single full pass over a 5 000-bar series
| Indicator | Wickra | finta | talipp |
|---------------------|---------------------|------------------------|------------------------------|
| SMA(20) | **26.0 µs** | 295.3 µs (11.4× slower) | 1 812.8 µs (69.7× slower) |
| EMA(20) | **16.8 µs** | 205.5 µs (12.2× slower) | 2 534.4 µs (150.9× slower) |
| RSI(14) | **31.2 µs** | 714.1 µs (22.9× slower) | 3 751.7 µs (120.2× slower) |
| MACD(12, 26, 9) | **30.8 µs** | 359.5 µs (11.7× slower) | 11 642.2 µs (378.0× slower) |
| Bollinger(20, 2.0) | **26.7 µs** | 690.6 µs (25.9× slower) | 27 482.4 µs (1 030.1× slower) |
| ATR(14) | **40.6 µs** | 1 120.3 µs (27.6× slower) | 3 760.2 µs (92.7× slower) |
### Streaming — per-tick latency after seeding with 2 000 historical bars
| Indicator | Wickra (per tick) | talipp (per tick) |
|-----------|---------------------|---------------------------|
| RSI(14) | **0.07 µs** | 1.16 µs (17.5× slower) |
The streaming gap widens linearly with how much history a batch-only library
has to recompute on every new tick; the table above is the gap at a modest
2 000-bar seed.
## Practical consequences
- **Mix freely.** A common pattern is "warm up the indicator on historical
bars in one `batch` call, then drive it tick-by-tick with `update` for
live data". This is correct because the two paths share state.
- **`is_ready()` is the safe gate.** Don't use a `len(prices) > warmup_period`
check; trust the indicator's `is_ready()` method, which is `true` exactly
when at least one `Some` value has been emitted.
- **Multi-output indicators NaN/None together.** Every column of a MACD or
Bollinger batch transitions from `NaN` to a real value on the same row.
Use `~np.isnan(out[:, 0])` (Python) or `Number.isFinite(row[0])`
(Node) as a single mask across all columns.
## See also
- [Quickstart: Python](Quickstart-Python.md) — concrete Python usage of both
paths.
- [Quickstart: Rust](Quickstart-Rust.md) — the `BatchExt` trait and `?`
error handling.
- [Warmup Periods](Warmup-Periods.md) — the exact `warmup_period()` for
every indicator.
- Source: <https://github.com/kingchenc/wickra>
+120
View File
@@ -0,0 +1,120 @@
# Warmup Periods
Every Wickra indicator returns `None` (Rust), `None` (Python), or `null`
(Node) for its first few inputs while it gathers enough data to produce a
defined value. The number of inputs an indicator needs before it emits its
first non-empty value is its **warmup period**, surfaced everywhere as
`warmup_period()` / `warmupPeriod()`.
After the first emission, the indicator never goes back to a "no value yet"
state — it has rolled its state forward and will produce a steady value on
every subsequent `update()`. Calling `reset()` returns to the warming-up
state, equivalent to a freshly constructed instance.
## How to read the formula column
The formulas below are taken verbatim from the `warmup_period()` methods in
`crates/wickra-core/src/indicators/<name>.rs`. The "Inputs at first
emission" column says, in 1-indexed terms, which `update()` call returns the
first `Some`/non-`NaN` value. They are the same number; "first emission
index" in 0-indexed terms is `warmup_period 1`.
## Single-output indicators
| Indicator | Constructor | Formula | `warmup_period()` for shown args | Inputs at first emission |
|-----------------|----------------------------------------------|----------------------------------|----------------------------------|--------------------------|
| `Sma` | `Sma::new(14)` | `period` | 14 | 14th |
| `Ema` | `Ema::new(14)` | `period` | 14 | 14th |
| `Wma` | `Wma::new(14)` | `period` | 14 | 14th |
| `Dema` | `Dema::new(14)` | `2 * period - 1` | 27 | 27th |
| `Tema` | `Tema::new(14)` | `3 * period - 2` | 40 | 40th |
| `Hma` | `Hma::new(14)` | `period + round(sqrt(period)).max(1) - 1` | 17 | 17th |
| `Kama` | `Kama::new(10, 2, 30)` | `er_period + 1` | 11 | 11th |
| `Rsi` | `Rsi::new(14)` | `period + 1` | 15 | 15th |
| `Cci` | `Cci::new(20)` | `period` | 20 | 20th |
| `Roc` | `Roc::new(12)` | `period + 1` | 13 | 13th |
| `WilliamsR` | `WilliamsR::new(14)` | `period` | 14 | 14th |
| `Mfi` | `Mfi::new(14)` | `period` | 14 | 14th |
| `Trix` | `Trix::new(15)` | `3 * period - 1` | 44 | 44th |
| `AwesomeOscillator` | `AwesomeOscillator::new(5, 34)` | `slow_period` | 34 | 34th |
| `Atr` | `Atr::new(14)` | `period` | 14 | 14th |
| `Psar` | `Psar::new(0.02, 0.20)` | constant `2` | 2 | 2nd |
| `Obv` | `Obv::new()` | constant `1` | 1 | 1st |
| `Vwap` | `Vwap::new()` | constant `1` | 1 | 1st |
| `RollingVwap` | `RollingVwap::new(20)` | `period` | 20 | 20th |
## Multi-output indicators
These indicators emit several values at once (a struct in Rust, a tuple in
Python, an object in Node) and every column / field transitions from "not
ready" to "ready" together — there are no rows that have a `signal` but no
`macd`, for example.
| Indicator | Constructor | Formula | `warmup_period()` for shown args | Inputs at first emission | Outputs |
|-------------------|--------------------------------------|------------------------------------------|----------------------------------|--------------------------|--------------------------------------------------------|
| `MacdIndicator` | `MacdIndicator::new(12, 26, 9)` | `slow + signal - 1` | 34 | 34th | `macd`, `signal`, `histogram` |
| `BollingerBands` | `BollingerBands::new(20, 2.0)` | `period` | 20 | 20th | `upper`, `middle`, `lower`, `stddev` |
| `Stochastic` | `Stochastic::new(14, 3)` | `k_period + d_period - 1` | 16 | 16th | `k`, `d` |
| `Adx` | `Adx::new(14)` | `2 * period` | 28 | 28th | `plus_di`, `minus_di`, `adx` |
| `Aroon` | `Aroon::new(14)` | `period + 1` | 15 | 15th | `up`, `down` |
| `Keltner` | `Keltner::new(20, 10, 2.0)` | `ema_period.max(atr_period)` | 20 | 20th | `upper`, `middle`, `lower` |
| `Donchian` | `Donchian::new(20)` | `period` | 20 | 20th | `upper`, `middle`, `lower` |
## "Off-by-one" cases worth memorising
A few indicators look like they should warm up at `period` but in fact need
`period + 1` inputs. The reason is always the same — they consume *diffs*
or *previous-close* differences, not the prices themselves, and the very
first input has nothing to diff against.
- **`Rsi::new(period)` warmup is `period + 1`.** RSI is based on Wilder's
smoothing over per-tick gains and losses. With 14 prices you only have 13
diffs; you need 15 prices to compute 14 diffs and seed `avg_gain` /
`avg_loss`. The Rust unit test that pins this is
`warmup_period_is_period_plus_one`:
```rust
let rsi = Rsi::new(14).unwrap();
assert_eq!(rsi.warmup_period(), 15);
```
- **`Roc::new(period)` warmup is `period + 1`.** ROC compares the current
price to the price `period` bars ago; that comparison only makes sense
starting at input `period + 1`.
- **`Aroon::new(period)` warmup is `period + 1`.** Aroon scans a `period + 1`-bar
window to find the bars-since-high and bars-since-low.
- **`Kama::new(er_period, ...)` warmup is `er_period + 1`.** Kaufman's
efficiency ratio needs `er_period` differences, which costs one extra
bar.
## Cross-checking from your own code
The cleanest way to verify any of these from your application code is the
indicator's own `warmup_period()`:
```rust
use wickra::{Indicator, MacdIndicator};
let macd = MacdIndicator::classic(); // (12, 26, 9)
assert_eq!(macd.warmup_period(), 34);
```
```python
import wickra as ta
assert ta.MACD(12, 26, 9).warmup_period() == 34
```
```javascript
const wickra = require('wickra');
const sma = new wickra.SMA(20);
console.log(sma.warmupPeriod()); // -> 20
```
(Note: as of `wickra@0.1.4`, `warmupPeriod()` is exposed on the Node
single-output classes but not on every multi-output class — consult
`bindings/node/index.d.ts` for the authoritative surface.)
## See also
- [Streaming vs Batch](Streaming-vs-Batch.md) — the `is_ready()` gate, and
why a `len(prices) > warmup_period` check is the wrong abstraction.
- [Indicator Chaining](Indicator-Chaining.md) — how warmups stack inside a
`Chain`.
- Source: <https://github.com/kingchenc/wickra>
@@ -0,0 +1,245 @@
# ADX
> Wilder's Average Directional Index — the smoothed strength of a trend,
> plus the two directional components (`+DI`, `DI`) that say which
> direction the trend is going.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum (directional) |
| Sub-category | directional / trend-strength |
| Input type | `Candle` |
| Output type | `AdxOutput { plus_di, minus_di, adx }` |
| Output range | each field in `[0, 100]` |
| Default parameters | `period = 14` (Python) |
| Warmup period | `2 · period` (28 for `period = 14`) |
| Interpretation | `adx > 25` means a meaningful trend; the dominant DI gives its direction |
## Formula
For each new candle at time `t` (with previous candle `t-1`):
```
+DM_t = high_t high_{t-1} if (high_t high_{t-1}) > (low_{t-1} low_t)
and (high_t high_{t-1}) > 0
= 0 otherwise
DM_t = low_{t-1} low_t if (low_{t-1} low_t) > (high_t high_{t-1})
and (low_{t-1} low_t) > 0
= 0 otherwise
TR_t = max(high_t low_t,
|high_t close_{t-1}|,
|low_t close_{t-1}|)
```
Wilder's smoothing is applied to all three series. Seeding is a simple
sum over the first `period` post-prev candles; after seeding the update
rule for any of these is
```
S_t = S_{t-1} S_{t-1} / period + X_t
```
where `X_t` is `TR_t`, `+DM_t`, or `DM_t`. The directional indicators
and DX then are
```
+DI_t = 100 · (+DM smoothed)_t / (TR smoothed)_t
DI_t = 100 · (DM smoothed)_t / (TR smoothed)_t
DX_t = 100 · |+DI_t DI_t| / (+DI_t + DI_t)
```
`ADX_t` is itself a Wilder-smoothed `DX` series, seeded as the mean of
the first `period` `DX` values, and then updated with `α = 1/period`:
```
ADX_t = (ADX_{t-1} · (period 1) + DX_t) / period
```
When `+DI + DI == 0`, `DX` is `0`; when `TR == 0`, both DI lines are
`0`. These are the divide-by-zero guards in `Adx::update`.
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `period` | `usize` | `14` | `>= 1` | Wilder smoothing length shared by `+DM`, `DM`, `TR`, and `ADX`. |
`Adx::new(0)` returns `Error::PeriodZero`.
## Inputs / Outputs
From `impl Indicator for Adx`:
```rust
type Input = Candle;
type Output = AdxOutput;
fn update(&mut self, candle: Candle) -> Option<AdxOutput>;
```
`AdxOutput`:
| Field | Description |
|-------|-------------|
| `plus_di` | Plus Directional Indicator (`+DI`) — strength of upward movement. |
| `minus_di` | Minus Directional Indicator (`DI`) — strength of downward movement. |
| `adx` | Average Directional Index — smoothed `|DX|`, a directionless trend-strength measure. |
Python's `ADX.batch(high, low, close)` returns a `(n, 3)` `float64` array
with columns `[plus_di, minus_di, adx]`; warmup rows are entirely `NaN`.
The streaming `update(candle)` returns a `(plus_di, minus_di, adx)`
tuple or `None`.
Node's `ADX.batch(high, low, close)` returns a flat `number[]` of length
`n * 3`, interleaved `[plus_di_0, minus_di_0, adx_0, plus_di_1, …]`.
Only `batch` is exposed on the Node binding — no `update`.
## Warmup
`warmup_period()` returns `2 · period`. The first candle just provides a
"previous" reference (no DM/TR can be computed yet); the next `period`
candles seed the smoothed `+DM`, `DM`, and `TR` sums; the next `period`
candles after that produce `DX` values that seed `ADX`. For `period =
14` that's `1 + 14 + 13 = 28` candles before the first full
`AdxOutput`, which matches `2 · 14 = 28`.
## Edge cases
- **Strong unidirectional trend.** If every candle is strictly higher
than the last (with `+DM` always positive, `DM` always zero), `+DI`
saturates at `100`, `DI` at `0`, and `ADX` climbs toward `100`. The
example below produces exactly that.
- **Flat market (no high/low movement).** Every `TR`, `+DM`, `DM` is
zero, so the divide-by-zero guards return `+DI = DI = 0` and `DX =
0`; `ADX` then sits at `0` indefinitely.
- **Reset.** `reset()` clears `prev`, all seed sums and counts, all
smoothed values, the DX buffer, and `adx_value`.
## Examples
### Rust
```rust
use wickra::{Adx, BatchExt, Candle, Indicator};
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64 * 2.0;
Candle::new(base + 0.5, base + 1.0, base - 0.5, base + 0.5, 1.0, 0).unwrap()
})
.collect();
let mut adx = Adx::new(14)?;
let out = adx.batch(&candles);
let v = out[27].unwrap();
println!("row 27 +DI={} -DI={} ADX={}", v.plus_di, v.minus_di, v.adx);
let v = out[39].unwrap();
println!("row 39 +DI={} -DI={} ADX={}", v.plus_di, v.minus_di, v.adx);
# Ok::<(), wickra::Error>(())
```
Verified output:
```
row 27 +DI=80 -DI=0 ADX=100
row 39 +DI=80 -DI=0 ADX=100
```
### Python
```python
import numpy as np
import wickra as ta
n = 40
i = np.arange(n, dtype=float)
base = 100.0 + i * 2.0
high = base + 1.0
low = base - 0.5
close = base + 0.5
adx = ta.ADX(14)
out = adx.batch(high, low, close)
print('warmup:', adx.warmup_period())
print('shape :', out.shape)
print('row 27:', out[27])
print('row 39:', out[39])
```
Verified output:
```
warmup: 28
shape : (40, 3)
row 27: [ 80. 0. 100.]
row 39: [ 80. 0. 100.]
```
### Node
```javascript
const wickra = require('wickra');
const n = 40;
const high = [], low = [], close = [];
for (let i = 0; i < n; i++) {
const b = 100 + i * 2;
high.push(b + 1);
low.push(b - 0.5);
close.push(b + 0.5);
}
const adx = new wickra.ADX(14);
const out = adx.batch(high, low, close);
console.log('len :', out.length);
console.log('row 27:', { plusDi: out[27 * 3], minusDi: out[27 * 3 + 1], adx: out[27 * 3 + 2] });
console.log('row 39:', { plusDi: out[39 * 3], minusDi: out[39 * 3 + 1], adx: out[39 * 3 + 2] });
```
Verified output:
```
len : 120
row 27: { plusDi: 80, minusDi: 0, adx: 100 }
row 39: { plusDi: 80, minusDi: 0, adx: 100 }
```
## Interpretation
- **Trend-strength bands.** `ADX < 20` is typically read as a ranging
market; `ADX > 25` as a "real" trend; `ADX > 40` as a strong trend.
ADX itself is direction-agnostic — you need `+DI` vs `DI` to know
which way the trend points.
- **DI crossover.** `+DI` crossing above `DI` is a bullish directional
signal; the mirror is bearish. Many traders only act on a crossover
when `ADX > 25` to filter out crossovers in a ranging market.
- **ADX peaks.** A rising ADX confirms trend continuation; a falling
ADX from a high level suggests the current trend is exhausting (even
if `+DI` still dominates `DI`).
## Common pitfalls
- **Long warmup.** ADX needs `2 · period` candles before the first
emission — twice as many as most other Wilder indicators. A common
bug is reusing an "RSI fits in `period + 1` bars" mental model and
reading garbage during the ADX warmup; check `is_ready()` or test
for `NaN` on the `adx` column.
- **Plotted on the same axis as DI.** `+DI`, `DI`, and `ADX` all live
in `[0, 100]` and are typically overlaid. The crossover signal is
between `+DI` and `DI` only — `ADX` does not cross either of them
for any directional meaning.
## References
- J. Welles Wilder, *New Concepts in Technical Trading Systems*, Trend
Research, 1978 — the original publication of `+DI`, `DI`, `DX`,
`ADX`, and the smoothing scheme they share with RSI and ATR.
## See also
- [Indicator: Rsi](Indicator-Rsi.md) — shares Wilder smoothing.
- [Indicator: Aroon](Indicator-Aroon.md) — alternative trend-strength
measure, range-based.
- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — trend-following
momentum, useful as a confirmation against `+DI` / `DI`.
- [Warmup Periods](../../Warmup-Periods.md) — the `2 · period` ADX entry.
@@ -0,0 +1,206 @@
# Aroon
> Tushar Chande's Aroon indicator — tracks the bars-since-highest-high
> and bars-since-lowest-low inside a `period + 1`-bar window, reported
> as percentages.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum (directional / trend-strength) |
| Sub-category | bounded directional pair |
| Input type | `Candle` |
| Output type | `AroonOutput { up, down }` |
| Output range | `up, down ∈ [0, 100]` |
| Default parameters | `period = 14` (Python) |
| Warmup period | `period + 1` (15 for `period = 14`) |
| Interpretation | `up > 70 && down < 30` strong uptrend (mirror for downtrend); crossovers as turn signals |
## Formula
Scan the rolling `period + 1`-bar window for the position of the highest
high and the position of the lowest low (with `0 = oldest`,
`period = newest`):
```
hh_idx_t = argmax_{i in 0..period} high_{t-period+i}
ll_idx_t = argmin_{i in 0..period} low_{t-period+i}
up_t = 100 · hh_idx_t / period
down_t = 100 · ll_idx_t / period
```
When the highest high lands on the most-recent bar (`hh_idx == period`),
`up == 100`; when it lands on the oldest bar in the window, `up == 0`.
The same holds for `down`.
In Wickra's implementation the scan uses `>=` / `<=`, so ties go to the
*latest* matching bar — which is why a perfectly flat window produces
`up = down = 100` rather than `0` (the latest bar is always tied with
the oldest).
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `period` | `usize` | `14` | `>= 1` | Lookback length. The internal window holds `period + 1` candles. |
`Aroon::new(0)` returns `Error::PeriodZero`.
## Inputs / Outputs
From `impl Indicator for Aroon`:
```rust
type Input = Candle;
type Output = AroonOutput;
fn update(&mut self, candle: Candle) -> Option<AroonOutput>;
```
`AroonOutput`:
| Field | Description |
|-------|-------------|
| `up` | `100 · bars_since_oldest_HH / period`, in `[0, 100]`. High = recent new high. |
| `down` | `100 · bars_since_oldest_LL / period`, in `[0, 100]`. High = recent new low. |
Python's `Aroon.batch(high, low)` returns a `(n, 2)` `float64` array
with columns `[up, down]`; warmup rows are `[NaN, NaN]`. Streaming
`update(candle)` returns a `(up, down)` tuple or `None`.
Node's `Aroon.batch(high, low)` returns a flat `number[]` of length
`n * 2`, interleaved `[up_0, down_0, up_1, down_1, …]`. Only `batch`
is exposed on the Node binding.
## Warmup
`warmup_period()` returns `period + 1`. Aroon scans `period + 1` bars
to find "bars since highest high" (which ranges over `0..period`), so
the indicator is not ready until exactly `period + 1` candles have
arrived. This is the same off-by-one as RSI and ROC, but for a
window-position reason rather than a diff reason.
## Edge cases
- **Pure uptrend.** Every new candle is a new high — `hh_idx` is always
the latest position, `up == 100`. The lowest low is the oldest
candle in the window, `down == 0`. Tests `pure_uptrend_aroon_up_100`
pin this.
- **Constant input.** Every candle's high is equal to every other
candle's high. The `>=` tiebreak in the scan means the most-recent
candle always wins both the HH and LL positions — both `up` and
`down` end up at `100`. (Be careful: this is *not* a neutral
reading; it is an artefact of the tiebreak rule.)
- **Reset.** `reset()` clears the candle buffer; the next `period + 1`
updates return `None`.
## Examples
### Rust
```rust
use wickra::{Aroon, BatchExt, Candle, Indicator};
let candles: Vec<Candle> = (1..=15)
.map(|i| Candle::new(i as f64, i as f64 + 1.0, i as f64 - 1.0, i as f64, 1.0, 0).unwrap())
.collect();
let mut aroon = Aroon::new(14)?;
let out = aroon.batch(&candles);
let v = out[14].unwrap();
println!("uptrend row 14 up={} down={}", v.up, v.down);
# Ok::<(), wickra::Error>(())
```
Verified output:
```
uptrend row 14 up=100 down=0
```
### Python
```python
import numpy as np
import wickra as ta
i = np.arange(1, 16, dtype=float)
high = i + 1.0
low = i - 1.0
aroon = ta.Aroon(14)
out = aroon.batch(high, low)
print('warmup:', aroon.warmup_period())
print('shape :', out.shape)
print('row 14:', out[14])
```
Verified output:
```
warmup: 15
shape : (15, 2)
row 14: [100. 0.]
```
### Node
```javascript
const wickra = require('wickra');
const high = [], low = [];
for (let i = 1; i <= 15; i++) {
high.push(i + 1);
low.push(i - 1);
}
const a = new wickra.Aroon(14);
const out = a.batch(high, low);
console.log('len :', out.length);
console.log('row 14:', { up: out[14 * 2], down: out[14 * 2 + 1] });
```
Verified output:
```
len : 30
row 14: { up: 100, down: 0 }
```
## Interpretation
- **Strong trend bands.** `up > 70` with `down < 30` indicates a strong
uptrend (new highs are recent, new lows are old); mirror for a
downtrend.
- **Crossover.** `up` crossing above `down` is a bullish trend-shift
signal; the mirror is bearish. Crossovers near `50/50` are weak
(the window has no clear leader); crossovers from `0`/`100` extremes
are strong.
- **Consolidation.** Both lines wandering near `50` means neither
recent highs nor recent lows are dominating — typical of a
range-bound market.
## Common pitfalls
- **Constant input gives `up == down == 100`, not `0` or `50`.** The
`>=` / `<=` tiebreak in the scan rewards the most-recent candle.
Treat constant or near-constant windows as a degenerate case; a
reading of `(100, 100)` is *not* a strong trend in both directions
— it is "no information".
- **`period + 1` warmup, not `period`.** Same off-by-one trap as RSI:
the indicator looks at a window of size `period + 1` so that
`bars_since_high` can range from `0` to `period`. Indexing your
output array as if it were ready at the `period`-th input gives you
one `NaN` / `None` row at the start you didn't expect.
## References
- Tushar Chande, "A New Tool for Technical Traders: The Aroon
Indicator", *Technical Analysis of Stocks & Commodities*, September
1995 — the original publication.
## See also
- [Indicator: Adx](Indicator-Adx.md) — alternative trend-strength
measure with explicit `+DI` / `DI` direction.
- [Indicator: Stochastic](Indicator-Stochastic.md) — also range-window
based, but reports close position rather than extremum age.
- [Warmup Periods](../../Warmup-Periods.md) — the `period + 1` family.
@@ -0,0 +1,196 @@
# AwesomeOscillator
> Bill Williams' Awesome Oscillator — the difference of two simple moving
> averages computed on the bar's median price `(high + low) / 2`.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | unbounded oscillator (zero-centred) |
| Input type | `Candle` |
| Output type | `f64` |
| Output range | unbounded (centred on 0; in price-difference units) |
| Default parameters | `fast = 5`, `slow = 34` (`AwesomeOscillator::classic()`, Python default) |
| Warmup period | `slow_period` (34 for the classic configuration) |
| Interpretation | zero-line cross; "saucer" and "twin-peaks" Bill Williams patterns |
## Formula
For each new candle, compute the median price:
```
median_t = (high_t + low_t) / 2
```
Then AO is the difference of two SMAs of that series:
```
AO_t = SMA_fast(median)_t SMA_slow(median)_t
```
There is no smoothing on top — the output is in the same units as the
input prices (a number, not a percent).
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `fast` | `usize` | `5` | `>= 1` and `< slow` | Fast SMA period over median price. |
| `slow` | `usize` | `34` | `>= 1` and `> fast` | Slow SMA period over median price. |
`AwesomeOscillator::new` returns `Error::PeriodZero` if either period is
zero and `Error::InvalidPeriod` if `fast >= slow`.
## Inputs / Outputs
From `impl Indicator for AwesomeOscillator`:
```rust
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
```
The `close` and `volume` fields on the input candle are ignored — only
`high` and `low` matter, via `Candle::median_price()`.
Python's `AwesomeOscillator.batch(high, low)` returns a 1-D `float64`
`np.ndarray`. Node's `AwesomeOscillator.batch(high, low)` returns a
flat `number[]`. Both produce `NaN` during warmup; only Python exposes
a streaming `update(candle)` method.
## Warmup
`warmup_period()` returns `slow_period`. The slow SMA is the slower of
the two SMAs, and because both consume the same median-price stream the
first time both have valid output is exactly the `slow_period`-th input.
For the classic `(5, 34)` configuration this is `34` — verified above.
## Edge cases
- **Constant input.** Both SMAs converge to the constant median price,
so `AO == 0` (test `constant_series_yields_zero`).
- **Reset.** `reset()` resets both SMAs; the next `slow_period` updates
return `None`.
## Examples
### Rust
```rust
use wickra::{AwesomeOscillator, BatchExt, Candle, Indicator};
let candles: Vec<Candle> = (0..40)
.map(|i| {
let m = 100.0 + i as f64;
Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap()
})
.collect();
let mut ao = AwesomeOscillator::classic();
let out = ao.batch(&candles);
println!("row 33 = {}", out[33].unwrap());
println!("row 39 = {}", out[39].unwrap());
```
Verified output:
```
row 33 = 14.5
row 39 = 14.5
```
(`SMA(5) SMA(34)` on a unit-slope ramp converges to a constant offset
that depends only on the difference between the two windows' centres,
which is why both rows print the same number.)
### Python
```python
import numpy as np
import wickra as ta
n = 40
i = np.arange(n, dtype=float)
m = 100.0 + i
high = m + 1.0
low = m - 1.0
ao = ta.AwesomeOscillator(5, 34)
out = ao.batch(high, low)
print('warmup:', ao.warmup_period())
print('row 33:', out[33])
print('row 39:', out[39])
```
Verified output:
```
warmup: 34
row 33: 14.5
row 39: 14.5
```
### Node
```javascript
const wickra = require('wickra');
const n = 40;
const high = [], low = [];
for (let i = 0; i < n; i++) {
const m = 100 + i;
high.push(m + 1);
low.push(m - 1);
}
const ao = new wickra.AwesomeOscillator(5, 34);
const out = ao.batch(high, low);
console.log('row 33:', out[33]);
console.log('row 39:', out[39]);
```
Verified output:
```
row 33: 14.5
row 39: 14.5
```
## Interpretation
- **Zero-line cross.** AO crossing zero from below is a bullish
momentum signal — the fast SMA of median price has overtaken the
slow SMA. The mirror cross is bearish.
- **Saucer.** A short sequence of bars where AO turns from negative to
positive momentum without crossing zero (two declining-magnitude
bars on the same side of zero followed by a turn) is Bill Williams'
"saucer" pattern.
- **Twin peaks.** Two AO peaks on the same side of the zero line, with
the second peak lower (or shallower) than the first while price
pushes further, is Williams' divergence-style "twin peaks" pattern.
## Common pitfalls
- **Median-price input, not close.** AO ignores `close` entirely. If
your data source reports an "average" price or only closes, you must
reconstruct `high` and `low` or pick a different oscillator (e.g.
MACD on closes).
- **Output magnitude depends on the asset.** Because AO is in raw
price units, an AO of `14.5` on a price ramp through `100..140`
means something completely different than `14.5` on a price stream
near `0.00012`. Always interpret AO relative to a per-asset baseline
or normalise by ATR.
## References
- Bill Williams, *Trading Chaos: Applying Expert Techniques to
Maximize Your Profits*, Wiley, 1995 — introduces the Awesome
Oscillator alongside the rest of the Profitunity tool set.
## See also
- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — sister
oscillator on closes (with an extra signal line on top).
- [Indicator: Trix](Indicator-Trix.md) — momentum oscillator on a
triple-smoothed series.
- [Warmup Periods](../../Warmup-Periods.md) — bare `slow_period`.
@@ -0,0 +1,199 @@
# CCI
> Commodity Channel Index — measures how far the current typical price
> deviates from its rolling mean, in units of mean absolute deviation
> scaled by Lambert's constant.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | unbounded oscillator |
| Input type | `Candle` |
| Output type | `f64` |
| Output range | unbounded (typically `[200, +200]` thanks to the 0.015 factor) |
| Default parameters | `period = 20` (Python) |
| Warmup period | `period` (20 for `period = 20`) |
| Interpretation | `> +100` overbought, `< 100` oversold (Lambert) |
## Formula
For each candle, compute the typical price `TP = (high + low + close) / 3`,
then over the rolling `period`-bar window:
```
SMA_TP_t = (TP_{t-period+1} + … + TP_t) / period
MAD_t = (1 / period) · Σ |TP_i SMA_TP_t| for i = t-period+1 … t
CCI_t = (TP_t SMA_TP_t) / (factor · MAD_t)
```
The default `factor` is Lambert's `0.015`, chosen empirically so that
roughly 7080 % of values fall inside `[100, +100]`. The implementation
exposes the factor through `Cci::with_factor(period, factor)` if you want
to retune it for an asset with very different volatility characteristics.
When `MAD == 0` (a perfectly flat window), the implementation returns `0`
rather than dividing by zero.
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `period` | `usize` | `20` | `>= 1` | Rolling window length for both the SMA of typical price and the MAD. |
| `factor` | `f64` | `0.015` (`Cci::new`) | `> 0`, finite | Lambert's scaling constant; configurable via `Cci::with_factor`. |
`Cci::new(0)` returns `Error::PeriodZero`. `Cci::with_factor(_, factor)`
returns `Error::NonPositiveMultiplier` when `factor <= 0` or non-finite.
## Inputs / Outputs
From `impl Indicator for Cci`:
```rust
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
```
Python's `CCI.batch(high, low, close)` returns a 1-D `float64` `np.ndarray`
with `NaN` during warmup. Node's `CCI.batch(high, low, close)` returns a
flat `number[]` (also `NaN` during warmup); the Node binding does not
expose a streaming `update()` (`bindings/node/index.d.ts` lists only
`constructor` and `batch`).
## Warmup
`warmup_period()` returns exactly `period`. CCI does not consume diffs —
it only needs `period` typical-price samples to populate its rolling
window before it can compute an SMA and MAD. In streaming terms, calls
`1..period` return `None`; the `period`-th call returns the first value.
## Edge cases
- **Flat input.** Every `TP` is the SMA, so `MAD == 0` and the
implementation returns `0.0` (test `flat_candles_yield_zero`). This
avoids the divide-by-zero that would otherwise produce `NaN` /
`±∞`.
- **Custom factor.** `Cci::with_factor(period, factor)` lets you replace
Lambert's `0.015`. Picking a smaller factor widens the typical range
of CCI values; picking a larger one compresses them.
- **Reset.** `reset()` clears the rolling window and the running sum,
returning the indicator to the freshly-constructed state.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Cci, Indicator};
let candles: Vec<Candle> = (0..25)
.map(|i| {
let m = 50.0 + i as f64;
Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap()
})
.collect();
let mut cci = Cci::new(20)?;
let out = cci.batch(&candles);
println!("row 19 = {}", out[19].unwrap());
println!("row 24 = {}", out[24].unwrap());
# Ok::<(), wickra::Error>(())
```
Verified output:
```
row 19 = 126.66666666666667
row 24 = 126.66666666666667
```
### Python
```python
import numpy as np
import wickra as ta
i = np.arange(25, dtype=float)
m = 50.0 + i
high = m + 1.0
low = m - 1.0
close = m
cci = ta.CCI(20)
out = cci.batch(high, low, close)
print('row 19:', out[19])
print('row 24:', out[24])
```
Verified output:
```
row 19: 126.66666666666667
row 24: 126.66666666666667
```
### Node
```javascript
const wickra = require('wickra');
const n = 25;
const high = [], low = [], close = [];
for (let i = 0; i < n; i++) {
const m = 50 + i;
high.push(m + 1);
low.push(m - 1);
close.push(m);
}
const cci = new wickra.CCI(20);
const out = cci.batch(high, low, close);
console.log('row 19:', out[19]);
console.log('row 24:', out[24]);
```
Verified output:
```
row 19: 126.66666666666667
row 24: 126.66666666666667
```
## Interpretation
- **±100 threshold.** Lambert's published convention is to treat values
above `+100` as overbought and below `100` as oversold. The choice
of `0.015` for the divisor is what makes the threshold meaningful;
changing the factor changes the threshold.
- **Zero-line cross.** `CCI` crossing zero says the typical price has
moved through its `period`-bar mean — sometimes used as a
trend-direction filter.
- **Divergence.** As with RSI/Stochastic, a price making a new high
while CCI makes a lower high is a classic bearish divergence.
## Common pitfalls
- **CCI is unbounded.** Unlike RSI or Stochastic, CCI can spike well
outside `±100` in volatile markets. Threshold-based rules should be
paired with a maximum-absolute-value guard, or you will mis-classify
legitimate breakouts as "extreme overbought".
- **The 0.015 factor is empirical, not derived.** It was chosen by
Lambert in 1980 for commodity futures markets. Modern equities and
crypto have wider distributions; if your `|CCI|` distribution sits
almost entirely outside `±100`, retune via `Cci::with_factor` rather
than rewriting downstream thresholds.
## References
- Donald Lambert, "Commodity Channel Index: Tools for Trading Cyclical
Trends", *Commodities Magazine*, October 1980 — the original
publication, including the empirical choice of `0.015`.
## See also
- [Indicator: Rsi](Indicator-Rsi.md) — bounded sibling for comparison.
- [Indicator: WilliamsR](Indicator-WilliamsR.md) — another candle-input
oscillator, range-based rather than deviation-based.
- [Indicator: Mfi](Indicator-Mfi.md) — volume-weighted RSI; useful as a
confirmation alongside CCI.
- [Warmup Periods](../../Warmup-Periods.md) — `period` (no off-by-one).
@@ -0,0 +1,216 @@
# MacdIndicator
> Moving Average Convergence Divergence — the difference of two EMAs, with
> a third EMA on top as the signal line.
The Rust struct is `MacdIndicator` (since `Macd` would collide with the
output struct on case-insensitive file systems and several existing trait
imports). The Python and Node bindings expose the same engine under the
shorter, conventional name `MACD`.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | unbounded oscillator (trend-following) |
| Input type | `f64` (close) |
| Output type | `MacdOutput { macd, signal, histogram }` |
| Output range | unbounded (centred on 0) |
| Default parameters | `fast = 12`, `slow = 26`, `signal = 9` (`MacdIndicator::classic()`) |
| Warmup period | `slow + signal 1` (34 for the classic configuration) |
| Interpretation | crossovers of `macd` and `signal`; zero-line crosses; histogram momentum |
## Formula
```
EMA_n(x) = exponential moving average of x over n periods
(Wickra's EMA seeds from a simple average of the first n inputs)
macd_t = EMA_fast(close)_t EMA_slow(close)_t
signal_t = EMA_signal(macd)_t
hist_t = macd_t signal_t
```
The signal EMA does not start consuming inputs until `macd_t` becomes
defined (i.e. until both the fast and slow EMAs have seeded), which is
why the overall warmup is `slow + signal 1` rather than
`max(slow, signal)`.
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `fast` | `usize` | `12` | `>= 1` and `< slow` | Fast EMA period. |
| `slow` | `usize` | `26` | `>= 1` and `> fast` | Slow EMA period. |
| `signal` | `usize` | `9` | `>= 1` | EMA period applied to the raw MACD line. |
`MacdIndicator::new` returns `Error::PeriodZero` if any period is zero and
`Error::InvalidPeriod` if `fast >= slow`.
## Inputs / Outputs
From `impl Indicator for MacdIndicator`:
```rust
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, input: f64) -> Option<MacdOutput>;
```
`MacdOutput` carries three fields:
| Field | Description |
|-------|-------------|
| `macd` | `EMA(fast) EMA(slow)` of the input series. |
| `signal` | `EMA(signal)` of `macd`. |
| `histogram` | `macd signal`. |
Python's `MACD.batch(prices)` returns a `(n, 3)` `float64` array with
columns `[macd, signal, histogram]`; warmup rows are entirely `NaN`.
Node's `MACD.batch(prices)` returns a flat `number[]` of length `n * 3`
in the same interleaved order: index `i*3 + 0` is `macd`, `i*3 + 1` is
`signal`, `i*3 + 2` is `histogram`. The streaming `update(value)` returns
a `{ macd, signal, histogram }` object (or `null` during warmup).
## Warmup
`warmup_period()` returns `slow + signal 1`. The slow EMA seeds at
input `slow`; from that point onward the signal EMA starts receiving
`macd` values, and needs `signal 1` further inputs to seed itself.
For the classic `(12, 26, 9)` configuration this gives `26 + 9 1 = 34`
inputs before the first complete `MacdOutput` is emitted, as pinned by
the unit test `first_emission_matches_warmup_period`.
## Edge cases
- **Constant input.** Both EMAs converge to the constant value, so `macd`
approaches `0`; with no movement in `macd`, the signal EMA also
approaches `0`, and so does the histogram. The Rust test
`constant_series_yields_zero_macd_eventually` pins this.
- **Non-finite input.** `update(NaN)` or `update(±∞)` returns the
previously emitted `MacdOutput` without advancing any internal EMA.
- **Reset.** `reset()` resets all three EMAs and clears `last`. The next
`warmup_period()` calls return `None` again.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, MacdIndicator};
let prices: Vec<f64> = (0..40).map(|i| 100.0 + i as f64 * (20.0 / 39.0)).collect();
let mut macd = MacdIndicator::classic();
let out = macd.batch(&prices);
let v = out[33].unwrap();
println!("row 33 macd={} signal={} hist={}", v.macd, v.signal, v.histogram);
let v = out[39].unwrap();
println!("row 39 macd={} signal={} hist={}", v.macd, v.signal, v.histogram);
```
Verified output:
```
row 33 macd=3.589743589743577 signal=3.5897435897435788 hist=-0.0000000000000017763568394002505
row 39 macd=3.589743589743591 signal=3.589743589743585 hist=0.000000000000006217248937900877
```
### Python
```python
import numpy as np
import wickra as ta
prices = np.linspace(100.0, 120.0, 40)
macd = ta.MACD(12, 26, 9)
out = macd.batch(prices)
print('shape :', out.shape)
print('warmup:', macd.warmup_period())
print('row 33:', out[33])
print('row 39:', out[39])
```
Verified output:
```
shape : (40, 3)
warmup: 34
row 33: [ 3.58974359e+00 3.58974359e+00 -1.77635684e-15]
row 39: [3.58974359e+00 3.58974359e+00 6.21724894e-15]
```
### Node
```javascript
const wickra = require('wickra');
const macd = new wickra.MACD(12, 26, 9);
const prices = Array.from({ length: 40 }, (_, i) => 100 + i * 20 / 39);
const flat = macd.batch(prices);
console.log('flat length:', flat.length);
console.log('row 33 macd :', flat[33 * 3]);
console.log('row 33 signal:', flat[33 * 3 + 1]);
console.log('row 33 hist :', flat[33 * 3 + 2]);
console.log('row 39 macd :', flat[39 * 3]);
console.log('row 39 signal:', flat[39 * 3 + 1]);
console.log('row 39 hist :', flat[39 * 3 + 2]);
```
Verified output:
```
flat length: 120
row 33 macd : 3.589743589743577
row 33 signal: 3.5897435897435788
row 33 hist : -1.7763568394002505e-15
row 39 macd : 3.589743589743591
row 39 signal: 3.589743589743585
row 39 hist : 6.217248937900877e-15
```
## Interpretation
- **Signal-line crossover.** `macd` crossing above `signal` is the canonical
bullish signal; the symmetric crossover below is bearish. The
`histogram` makes this explicit — it crosses zero on the same bar.
- **Zero-line crossover.** `macd` crossing above zero says the fast EMA
has overtaken the slow EMA; a longer-term trend confirmation, weaker
than the signal-line cross.
- **Histogram momentum.** Rising histogram bars (even while negative)
indicate that bearish momentum is fading, and vice versa. Traders use
this to anticipate signal-line crosses.
## Common pitfalls
- **The signal line lags the MACD line by `signal_period` bars.** A
crossover signal therefore arrives one full EMA-cycle after the
underlying momentum turn, which is why MACD is a *confirmation*
indicator, not a leading one.
- **`fast >= slow` is rejected.** A common bug when reading
parameters from a config file is swapping the two — the constructor
returns `Error::InvalidPeriod` rather than silently producing an
inverted MACD line.
- **Don't slice a single column out of a warmup row.** During the first
`slow + signal 1` inputs every field is `NaN` (Python) or absent
(`None` in Rust / `null` in Node). Filter by checking `macd` for
finiteness before reading `signal` or `histogram`.
## References
- Gerald Appel, *Technical Analysis: Power Tools for Active Investors*,
Financial Times Prentice Hall, 2005 — the canonical modern treatment
of the MACD line/signal-line/histogram trio Appel popularised in the
late 1970s.
## See also
- [Indicator: Rsi](Indicator-Rsi.md) — bounded sibling oscillator, useful
as a confirmation filter on top of MACD signals.
- [Indicator: Trix](Indicator-Trix.md) — another EMA-based momentum
oscillator (triple-smoothed rate of change).
- [Warmup Periods](../../Warmup-Periods.md) — table including the `slow +
signal 1` rule.
- [Quickstart: Python](../../Quickstart-Python.md) — MACD multi-column NaN
pattern explained.
@@ -0,0 +1,204 @@
# MFI
> Money Flow Index — a volume-weighted RSI built on typical price times
> volume.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | bounded oscillator (volume-driven) |
| Input type | `Candle` (volume needed) |
| Output type | `f64` |
| Output range | `[0, 100]` |
| Default parameters | `period = 14` (Python) |
| Warmup period | `period` (14 for `period = 14`) |
| Interpretation | overbought above 80, oversold below 20 |
## Formula
For each new candle:
```
TP_t = (high_t + low_t + close_t) / 3 (typical price)
MF_t = TP_t · volume_t (money flow)
positive MF = MF_t if TP_t > TP_{t-1}, else 0
negative MF = MF_t if TP_t < TP_{t-1}, else 0
(both zero when TP_t == TP_{t-1})
```
Maintain rolling sums of positive and negative money flow over the last
`period` bars. Then:
```
MR_t = positive_sum / negative_sum
MFI_t = 100 100 / (1 + MR_t)
```
The implementation guards both special cases: when both rolling sums are
zero, MFI returns `50` (neutral); when only `negative_sum == 0`, MFI
returns `100`; otherwise the standard formula.
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `period` | `usize` | `14` | `>= 1` | Rolling window length for the positive/negative money-flow sums. |
`Mfi::new(0)` returns `Error::PeriodZero`.
## Inputs / Outputs
From `impl Indicator for Mfi`:
```rust
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
```
Volume is consumed via `candle.volume` — it is not optional. Calling
the indicator with a zero-volume candle is legal (every money flow on
that bar is zero), but mass zero-volume bars will dilute the sums.
Python's `MFI.batch(high, low, close, volume)` returns a 1-D `float64`
`np.ndarray` (warmup → `NaN`). Node's `MFI.batch(high, low, close,
volume)` returns a flat `number[]` (warmup → `NaN`); only `batch` is
exposed on the Node binding.
## Warmup
`warmup_period()` returns `period`. The first candle has no previous
`TP` to compare against, so its money flow is classified as neither
positive nor negative — it sits in the window as a `0 / 0` slot but
still counts toward filling the window. The first `Some` is therefore
emitted at the `period`-th `update`, exactly when the rolling positive
and negative sums first contain `period 1` real comparisons.
## Edge cases
- **Pure uptrend.** Every `TP_t > TP_{t-1}`, so `negative_sum == 0` and
the implementation returns `100` directly (test
`pure_uptrend_yields_high_mfi`). Pure downtrend mirrors at `0` (test
`pure_downtrend_yields_low_mfi`).
- **Flat input (all `TP` equal).** Both sums stay at zero; the
implementation returns `50` (the same neutral convention as RSI on
flat input).
- **Zero-volume candle.** Money flow on that bar is zero. The window
still advances; the indicator just gets one less data point of
influence.
- **Reset.** `reset()` clears `prev_tp`, both rolling windows, and both
sums.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, Mfi};
let candles: Vec<Candle> = (1..=20)
.map(|i| Candle::new(i as f64, i as f64, i as f64, i as f64, 100.0, 0).unwrap())
.collect();
let mut mfi = Mfi::new(14)?;
let out = mfi.batch(&candles);
println!("row 13 = {}", out[13].unwrap());
println!("row 19 = {}", out[19].unwrap());
# Ok::<(), wickra::Error>(())
```
Verified output:
```
row 13 = 100
row 19 = 100
```
### Python
```python
import numpy as np
import wickra as ta
n = 20
i = np.arange(1, n + 1, dtype=float)
high = low = close = i
volume = np.full(n, 100.0)
mfi = ta.MFI(14)
out = mfi.batch(high, low, close, volume)
print('warmup:', mfi.warmup_period())
print('row 13:', out[13])
print('row 19:', out[19])
```
Verified output:
```
warmup: 14
row 13: 100.0
row 19: 100.0
```
### Node
```javascript
const wickra = require('wickra');
const n = 20;
const high = [], low = [], close = [], vol = [];
for (let i = 1; i <= n; i++) {
high.push(i); low.push(i); close.push(i); vol.push(100);
}
const m = new wickra.MFI(14);
const out = m.batch(high, low, close, vol);
console.log('row 13:', out[13]);
console.log('row 19:', out[19]);
```
Verified output:
```
row 13: 100
row 19: 100
```
## Interpretation
- **Overbought / oversold.** The conventional MFI thresholds are
`80 / 20` — tighter than RSI's `70 / 30` because the volume weighting
amplifies sustained one-way moves.
- **Divergence.** MFI divergences are read like RSI divergences: a new
price high without a confirming MFI high is bearish, and vice versa.
Because volume is in the mix, MFI divergences are often interpreted
as "the move is happening on weak participation" — i.e. structurally
more meaningful than a pure-price divergence.
- **Compare with OBV.** OBV (the unsmoothed cumulative volume) tells
you accumulated participation; MFI tells you participation pressure
over a fixed horizon. The two often diverge interestingly near
trend exhaustion.
## Common pitfalls
- **MFI requires volume.** Unlike RSI (close only) or Stochastic
(high/low/close), MFI's per-bar money flow is `TP × volume`. Passing
a candle stream with `volume == 0` throughout will collapse MFI to
`50` regardless of price action. Validate your data source before
reaching for MFI.
- **Same flat-input convention as RSI.** A perfectly flat window yields
`50` (not `NaN`, not "no value"). Treat the value as informational
only until the underlying TP series starts moving.
## References
- Gene Quong and Avrum Soudack, "Volume-Weighted RSI: Money Flow",
*Technical Analysis of Stocks & Commodities*, March 1989 — the
original publication of the MFI as a volume-weighted RSI variant.
## See also
- [Indicator: Rsi](Indicator-Rsi.md) — the price-only ancestor.
- [Indicator: Adx](Indicator-Adx.md) — directional/trend strength to
pair with MFI's overbought/oversold reading.
- [Warmup Periods](../../Warmup-Periods.md) — bare `period` (no off-by-one).
@@ -0,0 +1,173 @@
# ROC
> Rate of Change — the percent change between the current close and the
> close `period` bars ago.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | unbounded oscillator |
| Input type | `f64` (close) |
| Output type | `f64` |
| Output range | unbounded (centred on 0; expressed as a percent) |
| Default parameters | none — `period` is required in every binding |
| Warmup period | `period + 1` (13 for `period = 12`) |
| Interpretation | sign and magnitude of momentum; zero-line crossover for direction changes |
## Formula
```
ROC_t = (close_t close_{t period}) / close_{t period} · 100
```
When `close_{t period}` is exactly zero, the implementation returns
`0.0` rather than dividing by zero. The unit test `known_value` pins the
basic case: with `period = 3`, inputs `[100, 105, 108, 110]` produce
ROC `= 10` at index 3 (because `(110 100) / 100 · 100 = 10`).
## Parameters
| Name | Type | Default | Valid range | Description |
|------|------|---------|-------------|-------------|
| `period` | `usize` | required | `>= 1` | Lookback distance for the comparison close. |
`Roc::new(0)` returns `Error::PeriodZero`. The Python and Node bindings
do **not** assign a default for `period`; you must pass it explicitly.
## Inputs / Outputs
From `impl Indicator for Roc`:
```rust
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64>;
```
Python's `ROC.batch(prices)` returns a 1-D `float64` `np.ndarray`. Node's
`ROC.batch(prices)` returns a flat `number[]`. Streaming `update(price)`
returns a scalar (`float` / `number`) or `None` / `null` during warmup.
## Warmup
`warmup_period()` returns `period + 1`. The reason is the same off-by-one
as RSI: ROC compares against the close `period` bars ago, so at the
`period`-th input we still have nothing to look back at — the `(period +
1)`-th input is the first one for which `close_{t period}` exists.
Internally the rolling buffer is sized `period + 1`.
## Edge cases
- **Constant input.** Every diff is zero, so `ROC == 0` for every emitted
value (test `constant_series_yields_zero`).
- **Reference close of zero.** Treated as `0.0` rather than producing
`NaN`/`±∞` — see the `prev == 0.0` early return in `update`. This
matters for assets quoted with zero as a legitimate value (rare for
prices, but possible for, e.g., yield spreads).
- **Non-finite input.** `update(NaN)` or `update(±∞)` returns `None`
without advancing the rolling buffer.
- **Reset.** `reset()` clears the rolling buffer; the next `period + 1`
updates return `None`.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Roc};
let mut roc = Roc::new(3)?;
let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]);
println!("ROC(3) at idx 3 = {}", out[3].unwrap());
# Ok::<(), wickra::Error>(())
```
Verified output:
```
ROC(3) at idx 3 = 10
```
### Python
```python
import wickra as ta
roc = ta.ROC(3)
print('warmup:', roc.warmup_period())
for p in [100.0, 105.0, 108.0, 110.0]:
print(p, '->', roc.update(p))
```
Verified output:
```
warmup: 4
100.0 -> None
105.0 -> None
108.0 -> None
110.0 -> 10.0
```
### Node
```javascript
const wickra = require('wickra');
const roc = new wickra.ROC(3);
console.log('warmup:', roc.warmupPeriod());
for (const p of [100, 105, 108, 110]) {
console.log(p, '->', roc.update(p));
}
```
Verified output:
```
warmup: 4
100 -> null
105 -> null
108 -> null
110 -> 10
```
## Interpretation
- **Sign.** Positive ROC means price is higher than `period` bars ago;
negative means lower. The magnitude is the percent move.
- **Zero-line crossover.** A move through zero signals a regime change
in the `period`-bar horizon. Combined with a longer-period ROC, this
gives you a poor-man's trend filter.
- **Divergence.** A new price high paired with a lower ROC high is the
same bearish-divergence pattern as RSI/Stochastic, with the
unbounded-oscillator caveat that "lower high" is unambiguous (no
saturation against a `100` ceiling).
## Common pitfalls
- **ROC is unbounded.** A 10× price spike over `period` bars produces
`ROC = 900`. Don't pipe ROC directly into rule sets designed for
bounded oscillators (RSI, %K, %R) without an explicit clamp or a
log-return transformation upstream.
- **Off-by-one on the warmup.** The first non-`None` value lands at the
`(period + 1)`-th input, not the `period`-th. A common bug is sizing
an output array as `len(prices) - period` and getting an off-by-one
empty row at the end.
## References
- Robert Colby, *The Encyclopedia of Technical Market Indicators*,
2nd ed., McGraw-Hill, 2002 — Chapter on Rate of Change / Momentum,
covering the canonical percent and ratio formulations.
## See also
- [Indicator: Rsi](Indicator-Rsi.md) — same `period + 1` warmup, but
bounded.
- [Indicator: Trix](Indicator-Trix.md) — also a rate of change, but on
a triple-smoothed EMA.
- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — momentum
cousin operating on EMA differences instead of raw close differences.
- [Warmup Periods](../../Warmup-Periods.md) — the `period + 1` family.
@@ -0,0 +1,214 @@
# RSI
> Relative Strength Index — Wilder's bounded momentum oscillator that maps
> the ratio of average gains to average losses onto the `[0, 100]` range.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | bounded oscillator |
| Input type | `f64` (close) |
| Output type | `f64` |
| Output range | `[0, 100]` |
| Default parameters | `period = 14` (Python) |
| Warmup period | `period + 1` (15 for `period = 14`) |
| Interpretation | overbought above 70, oversold below 30 (Wilder's thresholds) |
## Formula
```
diff_t = close_t close_{t-1}
gain_t = max(diff_t, 0)
loss_t = max(diff_t, 0)
Seed (Wilder, at t = period):
avg_gain_p = (gain_1 + … + gain_p) / p
avg_loss_p = (loss_1 + … + loss_p) / p
Recursive smoothing (t > period), with α = 1 / period:
avg_gain_t = (avg_gain_{t-1} · (period 1) + gain_t) / period
avg_loss_t = (avg_loss_{t-1} · (period 1) + loss_t) / period
RS_t = avg_gain_t / avg_loss_t
RSI_t = 100 100 / (1 + RS_t)
```
When `avg_loss_t == 0` and `avg_gain_t > 0`, RSI is `100` directly; when both
are zero (a perfectly flat series) the implementation returns the standard
`50` convention.
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `period` | `usize` | `14` | `>= 1` | Wilder smoothing length. `Rsi::new(0)` returns `Error::PeriodZero`. |
## Inputs / Outputs
From `impl Indicator for Rsi` in `crates/wickra-core/src/indicators/rsi.rs`:
```rust
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64>;
```
The output is a scalar in `[0, 100]`. In Python `batch(prices)` returns a
1-D `np.ndarray` of `float64`, with `NaN` in the warmup positions. In Node
`batch(prices)` returns a flat `number[]`, also `NaN` during warmup.
## Warmup
`warmup_period()` returns `period + 1`. The reason is that RSI consumes
*diffs*, not prices: with `period` prices you only have `period 1` diffs,
so you need exactly one extra price before Wilder's seed average is well
defined. The Rust test `warmup_period_is_period_plus_one` pins this:
```rust
let rsi = Rsi::new(14).unwrap();
assert_eq!(rsi.warmup_period(), 15);
```
In streaming terms, the first `period` calls to `update()` return `None`;
the `(period + 1)`-th call returns the first `Some(value)`.
## Edge cases
- **Flat input.** When every input price is identical, every `gain` and
every `loss` is zero, so `avg_loss == avg_gain == 0`. The implementation
returns `50.0` by convention (see `Rsi::rsi_from_avgs`). The unit test
`flat_series_yields_rsi_50` pins this behaviour.
- **Pure uptrend / pure downtrend.** `avg_loss == 0` with `avg_gain > 0`
short-circuits to `100`; the mirror case returns `0`. Tests
`pure_uptrend_yields_rsi_100` and `pure_downtrend_yields_rsi_0` cover
this.
- **Non-finite input.** `update()` returns the previously emitted value
(or `None` if no value has been emitted yet) when the input is `NaN` or
infinite — the internal state is *not* advanced.
- **Reset.** `reset()` returns the indicator to the freshly-constructed
state: `prev_close`, both seed buffers, both averages, and `last_value`
are cleared.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Rsi};
let prices = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42,
45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00,
46.03, 46.41, 46.22, 45.64,
];
let mut rsi = Rsi::new(14)?;
let out = rsi.batch(&prices);
println!("first = {}", out[14].unwrap());
println!("last = {}", out[19].unwrap());
# Ok::<(), wickra::Error>(())
```
Verified output:
```
first = 70.46413502109705
last = 57.91502067008556
```
### Python
```python
import numpy as np
import wickra as ta
prices = np.array([
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42,
45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00,
46.03, 46.41, 46.22, 45.64,
], dtype=float)
rsi = ta.RSI(14)
v = rsi.batch(prices)
print("warmup:", rsi.warmup_period())
print("first :", float(v[14]))
print("last :", float(v[-1]))
```
Verified output:
```
warmup: 15
first : 70.46413502109705
last : 57.91502067008556
```
### Node
```javascript
const wickra = require('wickra');
const rsi = new wickra.RSI(14);
const prices = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42,
45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00,
46.03, 46.41, 46.22, 45.64,
];
const v = rsi.batch(prices);
console.log('warmup:', rsi.warmupPeriod());
console.log('first :', v[14]);
console.log('last :', v[19]);
```
Verified output:
```
warmup: 15
first : 70.46413502109705
last : 57.91502067008556
```
## Interpretation
- **Overbought / oversold zones.** Wilder's classic thresholds are `70`
(overbought) and `30` (oversold). Many crypto and FX desks tighten them
to `80 / 20` for trending markets and loosen to `60 / 40` for
range-bound markets.
- **Midline cross.** A move through `50` is sometimes used as a directional
signal; above 50 means average gains exceed average losses over the
smoothing window.
- **Divergence.** A higher price high paired with a lower RSI high (bearish
divergence) is a classic Wilder signal; the symmetric pattern at lows is
bullish.
## Common pitfalls
- **RSI on flat input is `50`, not undefined.** The implementation returns
`50.0` when both averages are zero. Do not interpret this as a neutral
signal — it is a placeholder that means "the indicator has no opinion
yet". Pair RSI with a volatility filter (e.g. ATR) if your strategy is
sensitive to ranging markets.
- **`period + 1` warmup, not `period`.** A common bug is sizing the result
array against `period` and indexing into the warmup region. The first
`Some` arrives at the *(period + 1)*-th `update`; in batch form, indices
`0..period` are `None`/`NaN`. See [Warmup Periods](../../Warmup-Periods.md).
- **Non-finite inputs are absorbed silently.** `update(f64::NAN)` does not
advance the state and returns the previous value. If you depend on a 1:1
input-to-output mapping, pre-validate your data before feeding it in.
## References
- J. Welles Wilder, *New Concepts in Technical Trading Systems*, Trend
Research, 1978. The original publication that defines both RSI and the
Wilder smoothing scheme used internally.
## See also
- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — also momentum,
but trend-following and unbounded.
- [Indicator: Stochastic](Indicator-Stochastic.md) — sibling bounded
oscillator, faster and noisier than RSI.
- [Warmup Periods](../../Warmup-Periods.md) — the canonical `period + 1`
off-by-one explained.
- [Quickstart: Python](../../Quickstart-Python.md) — full RSI batch / streaming
walk-through.
@@ -0,0 +1,220 @@
# Stochastic
> The fast Stochastic Oscillator — `%K` measures where the current close
> sits inside the high/low range of the last `k_period` bars, and `%D` is
> a short SMA on top of `%K`.
Wickra ships a single **fast** variant (`%K` is the raw oscillator value,
`%D` is its SMA). The "slow stochastic" wraps an additional SMA on `%K`;
that variant is not built in — if you need it, smooth `%K` yourself via
a `Chain` with `Sma::new(slow_period)`.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | bounded oscillator |
| Input type | `Candle` |
| Output type | `StochasticOutput { k, d }` |
| Output range | `k, d ∈ [0, 100]` |
| Default parameters | `k_period = 14`, `d_period = 3` (`Stochastic::classic()`) |
| Warmup period | `k_period + d_period 1` (16 for the classic configuration) |
| Interpretation | overbought above 80, oversold below 20; %K / %D crossovers |
## Formula
For each new candle at time `t`, let `HH` and `LL` be the highest high
and lowest low over the last `k_period` candles:
```
HH_t = max(high_{t-k_period+1}, …, high_t)
LL_t = min(low_{t-k_period+1}, …, low_t)
%K_t = 100 · (close_t LL_t) / (HH_t LL_t) when HH ≠ LL
%K_t = 50 when HH == LL (flat range)
%D_t = SMA_{d_period}(%K)_t
```
The implementation maintains `HH` and `LL` with two monotonic deques so
each update is amortized O(1).
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `k_period` | `usize` | `14` | `>= 1` | Lookback window for the `%K` extrema. |
| `d_period` | `usize` | `3` | `>= 1` | SMA period for `%D` over the `%K` stream. |
Either period being zero returns `Error::PeriodZero`.
## Inputs / Outputs
From `impl Indicator for Stochastic`:
```rust
type Input = Candle;
type Output = StochasticOutput;
fn update(&mut self, candle: Candle) -> Option<StochasticOutput>;
```
`StochasticOutput`:
| Field | Description |
|-------|-------------|
| `k` | Raw `%K` (where `close` sits inside the window's HL range). |
| `d` | `SMA(d_period)` of the `%K` series — the slower "signal" line. |
Python's `Stochastic.batch(high, low, close)` returns a `(n, 2)` array
with columns `[k, d]`; warmup rows are `[NaN, NaN]`.
Node's `Stochastic.batch(high, low, close)` returns a flat `number[]`
of length `n * 2`, interleaved as `[k_0, d_0, k_1, d_1, …]`. There is
no streaming `update()` on the Node binding — only `batch` is exposed.
## Warmup
`warmup_period()` returns `k_period + d_period 1`. The `%K` series itself
becomes available at input `k_period`; the `%D` SMA then needs `d_period`
of those `%K` values to seed, producing its first output at input
`k_period + d_period 1`. For the classic `(14, 3)` configuration this is
`16` — verified above.
## Edge cases
- **Flat range (`HH == LL`).** The implementation returns `%K = 50` by
convention (mirroring RSI's flat-input behaviour). The unit test
`flat_range_yields_k_50` pins this; with a constant input both `%K` and
`%D` collapse to `50`.
- **Close at the window high.** `%K = 100` exactly; close at the window
low gives `%K = 0` exactly (tests `close_at_high_yields_k_100` and
`close_at_low_yields_k_0`).
- **Reset.** `reset()` clears the candle buffer, both monotonic deques,
the SMA, and `last_k` — the indicator returns to a freshly-constructed
state.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, Stochastic};
let candles: Vec<Candle> = (0..20)
.map(|i| {
let m = 10.0 + (i as f64 * 0.5).sin() * 2.0;
Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap()
})
.collect();
let mut s = Stochastic::new(14, 3)?;
let out = s.batch(&candles);
let v = out[15].unwrap();
println!("row 15 k={} d={}", v.k, v.d);
let v = out[19].unwrap();
println!("row 19 k={} d={}", v.k, v.d);
# Ok::<(), wickra::Error>(())
```
Verified output:
```
row 15 k=81.19360374383255 d=69.94559370965067
row 19 k=47.26766986190959 d=62.55762656278284
```
### Python
```python
import numpy as np
import wickra as ta
n = 20
i = np.arange(n, dtype=float)
m = 10.0 + np.sin(i * 0.5) * 2.0
high = m + 1.0
low = m - 1.0
close = m
stoch = ta.Stochastic(14, 3)
out = stoch.batch(high, low, close)
print('shape :', out.shape)
print('warmup:', stoch.warmup_period())
print('row 15:', out[15])
print('row 19:', out[19])
```
Verified output:
```
shape : (20, 2)
warmup: 16
row 15: [81.19360374 69.94559371]
row 19: [47.26766986 62.55762656]
```
### Node
```javascript
const wickra = require('wickra');
const n = 20;
const high = [], low = [], close = [];
for (let i = 0; i < n; i++) {
const m = 10.0 + Math.sin(i * 0.5) * 2.0;
high.push(m + 1.0);
low.push(m - 1.0);
close.push(m);
}
const s = new wickra.Stochastic(14, 3);
const out = s.batch(high, low, close);
console.log('len :', out.length);
console.log('row 15 :', { k: out[15 * 2], d: out[15 * 2 + 1] });
console.log('row 19 :', { k: out[19 * 2], d: out[19 * 2 + 1] });
```
Verified output:
```
len : 40
row 15 : { k: 81.19360374383255, d: 69.94559370965067 }
row 19 : { k: 47.26766986190959, d: 62.55762656278284 }
```
## Interpretation
- **Overbought / oversold zones.** The canonical Lane thresholds are
`80` and `20`. Crossings back from outside these bands are typically
used as reversal-confirmation signals, not entries on their own.
- **`%K` / `%D` crossover.** `%K` crossing above `%D` from below is a
short-horizon bullish signal; the mirror cross is bearish.
- **Divergence.** A price making a new high but `%K` failing to confirm
is a classic bearish divergence — same logic as RSI divergence but on
a faster, range-based oscillator.
## Common pitfalls
- **`%K` on a flat candle window is `50`, not undefined.** During a
quiet drift where `HH == LL`, the convention used here is `50.0` and
`%D` therefore also converges to `50.0`. Do not interpret a sequence
of `50`s as a real oversold/overbought cycle — it is the silent-market
fallback path.
- **Wickra exposes only the fast variant.** "Slow stochastic" is `%K =
SMA(raw_%K, slow_k)` with `%D = SMA(%K, d_period)` on top. The
built-in `Stochastic` skips the first SMA; to reproduce the slow
variant, drive the raw `%K` (taken from `stoch.update(candle).k`)
through your own `Sma`.
## References
- George C. Lane, *Investment Educators* seminars and articles
(late 1950s, popularised through the 1980s) — the original
formulation of `%K` and `%D` as a fast oscillator.
## See also
- [Indicator: Rsi](Indicator-Rsi.md) — sister bounded oscillator, slower
and smoother than `%K`.
- [Indicator: WilliamsR](Indicator-WilliamsR.md) — the negated mirror of
fast `%K`, plotted on `[100, 0]`.
- [Warmup Periods](../../Warmup-Periods.md) — `k_period + d_period 1` rule
in context.
@@ -0,0 +1,187 @@
# TRIX
> Triple-EMA percent rate of change — applies three EMAs in sequence to
> smooth out short-term noise, then reports the one-bar percent change
> of the resulting series.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | unbounded oscillator (zero-centred) |
| Input type | `f64` (close) |
| Output type | `f64` |
| Output range | unbounded (typically a few percent, centred on 0) |
| Default parameters | none — `period` is required in every binding |
| Warmup period | `3 · period 1` (44 for `period = 15`) |
| Interpretation | zero-line crossings as trend-change cues; magnitude as momentum |
## Formula
Let `EMA_n(·)` denote Wickra's EMA over `n` periods (seeded from the
simple mean of the first `n` inputs, then recursive with `α = 2/(n+1)`).
For each input close, build a triple-smoothed series:
```
TR_t = EMA_period( EMA_period( EMA_period( close ) ) )_t
```
Then TRIX is the one-bar percent rate of change of `TR`:
```
TRIX_t = 100 · (TR_t TR_{t-1}) / TR_{t-1}
```
When `TR_{t-1} == 0` exactly, the implementation returns `0.0` rather
than dividing by zero.
## Parameters
| Name | Type | Default | Valid range | Description |
|------|------|---------|-------------|-------------|
| `period` | `usize` | required | `>= 1` | Period shared by all three EMAs. |
`Trix::new(0)` returns `Error::PeriodZero` (via the inner `Ema::new`).
The Python and Node bindings expose no default for `period`; you must
pass it explicitly.
## Inputs / Outputs
From `impl Indicator for Trix`:
```rust
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64>;
```
Python's `TRIX.batch(prices)` returns a 1-D `float64` `np.ndarray`
(warmup → `NaN`). Node's `TRIX.batch(prices)` returns a flat
`number[]` (warmup → `NaN`). Both also expose streaming `update(price)`.
## Warmup
`warmup_period()` returns `3 · period 1`. Three stacked EMAs of the
same period seed at input `3 · period 2`; once `TR` exists, TRIX
itself needs one more input to form the `TR_t TR_{t-1}` difference,
which lands at input `3 · period 1`. For `period = 15` this is
`3 · 15 1 = 44`, verified above.
## Edge cases
- **Constant input.** All three EMAs converge to the constant value, so
`TR_t TR_{t-1} == 0` and TRIX returns `0` (test
`constant_series_yields_zero_trix`).
- **`TR_{t-1} == 0`.** The implementation returns `0` rather than
producing `NaN` / `±∞`. This is the `Some(_)` branch with `prev !=
0.0`-failed in `Trix::update`.
- **Reset.** `reset()` resets all three EMAs and clears `prev_tr`.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Trix};
let prices: Vec<f64> = (1..=50).map(|i| i as f64).collect();
let mut trix = Trix::new(15)?;
let out = trix.batch(&prices);
println!("row 43 = {}", out[43].unwrap());
println!("row 49 = {}", out[49].unwrap());
# Ok::<(), wickra::Error>(())
```
Verified output:
```
row 43 = 4.545454545454546
row 49 = 3.5714285714285716
```
(The series decays toward zero as a ramp gets longer because the
percent change of an arithmetic ramp shrinks as the level grows.)
### Python
```python
import wickra as ta
trix = ta.TRIX(15)
print('warmup:', trix.warmup_period())
vals = []
for i in range(1, 51):
vals.append(trix.update(float(i)))
print('vals[43]:', vals[43])
print('vals[49]:', vals[49])
```
Verified output:
```
warmup: 44
vals[43]: 4.545454545454546
vals[49]: 3.5714285714285716
```
### Node
```javascript
const wickra = require('wickra');
const trix = new wickra.TRIX(15);
console.log('warmup:', trix.warmupPeriod());
const vals = [];
for (let i = 1; i <= 50; i++) vals.push(trix.update(i));
console.log('vals[43]:', vals[43]);
console.log('vals[49]:', vals[49]);
```
Verified output:
```
warmup: 44
vals[43]: 4.545454545454546
vals[49]: 3.5714285714285716
```
## Interpretation
- **Zero-line cross.** TRIX crossing above zero suggests the
triple-smoothed trend is turning up; crossing below, turning down.
Because of the triple smoothing, these crosses are deliberately
late and deliberately stable.
- **Magnitude.** A larger absolute TRIX value means the smoothed series
is changing faster per bar. There is no canonical "overbought" band
— TRIX is interpreted by its sign and slope, not by threshold.
- **Compare to MACD.** Both are EMA-based momentum oscillators on a
zero-centred scale. MACD reacts faster (two EMAs, one diff); TRIX
reacts slower (three EMAs, one rate of change), making it a
cleaner long-horizon trend filter.
## Common pitfalls
- **Long warmup.** `3 · period 1` is one of the largest warmups in
the library (44 for the canonical `period = 15`). Sizing your input
buffer to `period` and expecting values immediately will hand you
`None` / `NaN` for a full 44 bars.
- **Triple smoothing kills small wiggles.** TRIX deliberately ignores
short-term noise. Do not use it for entry-timing inside a fast
oscillator strategy; use it as a long-term trend filter on top of a
faster signal.
## References
- Jack Hutson, "Good TRIX", *Technical Analysis of Stocks &
Commodities*, July 1983 — the original publication popularising the
triple-EMA rate-of-change oscillator.
## See also
- [Indicator: MacdIndicator](Indicator-MacdIndicator.md) — faster
EMA-based momentum oscillator, useful as a confirmation against
TRIX zero-line crosses.
- [Indicator: Roc](Indicator-Roc.md) — the raw, one-stage rate of
change TRIX is built on top of.
- [Warmup Periods](../../Warmup-Periods.md) — `3 · period 1` entry.
@@ -0,0 +1,184 @@
# WilliamsR
> Williams %R — Larry Williams' negated mirror of fast Stochastic %K,
> plotted on `[100, 0]` instead of `[0, 100]`.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | bounded oscillator |
| Input type | `Candle` |
| Output type | `f64` |
| Output range | `[100, 0]` |
| Default parameters | `period = 14` (Python) |
| Warmup period | `period` (14 for `period = 14`) |
| Interpretation | overbought above `20`, oversold below `80` |
## Formula
For each new candle, let `HH` and `LL` be the highest high and lowest
low over the last `period` candles:
```
HH_t = max(high_{t-period+1}, …, high_t)
LL_t = min(low_{t-period+1}, …, low_t)
%R_t = 100 · (HH_t close_t) / (HH_t LL_t) when HH ≠ LL
%R_t = 50 when HH == LL (flat range)
```
This is the negation of fast Stochastic `%K` measured from the *top* of
the window: when the close sits at the window high, `%R = 0`; when it
sits at the window low, `%R = 100`.
## Parameters
| Name | Type | Default (Python) | Valid range | Description |
|------|------|------------------|-------------|-------------|
| `period` | `usize` | `14` | `>= 1` | Lookback window for the `HH` / `LL` extrema. |
`WilliamsR::new(0)` returns `Error::PeriodZero`.
## Inputs / Outputs
From `impl Indicator for WilliamsR`:
```rust
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
```
Python's `WilliamsR.batch(high, low, close)` returns a 1-D `float64`
`np.ndarray` (warmup → `NaN`). Node's `WilliamsR.batch(high, low, close)`
returns a flat `number[]` (warmup → `NaN`); only `batch` is exposed on
the Node binding.
## Warmup
`warmup_period()` returns `period`. Williams %R works on a rolling
range, not a rolling diff, so once `period` candles have arrived the
indicator is ready — there is no off-by-one. The first `period 1`
calls to `update()` return `None`; the `period`-th call returns the
first `Some(value)`.
## Edge cases
- **Close at the window high.** `%R == 0` exactly. The unit test
`close_at_high_yields_zero` pins this case (with H, L = 8, 10, 12 and
closes ending at 12, the result is `0`). Note that floating-point
zero can print as `-0` when scaled by `-100`; both compare equal to
`0`.
- **Close at the window low.** `%R == 100` exactly (test
`close_at_low_yields_minus_100`).
- **Flat range.** When `HH == LL`, the implementation returns `50` as
the neutral convention.
- **Reset.** `reset()` clears the candle buffer; the next `period`
updates return `None`.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, WilliamsR};
let candles = vec![
Candle::new(9.0, 10.0, 8.0, 9.0, 1.0, 0).unwrap(),
Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap(),
Candle::new(12.0, 12.0, 10.0, 12.0, 1.0, 0).unwrap(), // close == HH
];
let mut w = WilliamsR::new(3)?;
let out = w.batch(&candles);
println!("Williams %R(3) at idx 2 = {}", out[2].unwrap());
# Ok::<(), wickra::Error>(())
```
Verified output:
```
Williams %R(3) at idx 2 = -0
```
(`-0.0` is bit-equal to `0.0` in IEEE-754; the negative sign is just a
side effect of multiplying `+0.0` by `-100.0`.)
### Python
```python
import numpy as np
import wickra as ta
high = np.array([10.0, 11.0, 12.0])
low = np.array([8.0, 9.0, 10.0])
close = np.array([9.0, 10.0, 12.0])
w = ta.WilliamsR(3)
out = w.batch(high, low, close)
print('warmup:', w.warmup_period())
print('row 2 :', out[2])
```
Verified output:
```
warmup: 3
row 2 : -0.0
```
### Node
```javascript
const wickra = require('wickra');
const high = [10.0, 11.0, 12.0];
const low = [8.0, 9.0, 10.0];
const close = [9.0, 10.0, 12.0];
const w = new wickra.WilliamsR(3);
const out = w.batch(high, low, close);
console.log('row 2:', out[2]);
```
Verified output:
```
row 2: -0
```
## Interpretation
- **Larry Williams' thresholds.** `%R > 20` is overbought; `%R < 80`
is oversold. Because the scale runs from `100` (oversold) to `0`
(overbought), the inequalities feel inverted to anyone used to
Stochastic — but the *positions* of the bands are identical.
- **Failure swings.** A `%R` value that pokes into overbought, retreats,
then fails to reach overbought on the next rally is the classic
Williams "failure swing" — interpreted as bearish exhaustion.
- **Use alongside trend.** %R is a pure range oscillator; in a strong
trend it can stay pinned at `0` or `100` for many bars. Pair with
ADX or a moving-average filter before reading it as a reversal cue.
## Common pitfalls
- **Sign inversion.** Williams %R lives in `[100, 0]`, not `[0, 100]`.
Code that assumes "higher value = more bullish" will work; code that
assumes a positive range will silently mis-classify every value.
- **Mirror of fast %K, not slow.** Williams %R has no built-in
smoothing; it tracks raw `%K` (with a sign flip and a shift). If you
need a smoothed version, drive `%R` through your own `Sma` or `Ema`
via a `Chain`.
## References
- Larry Williams, *How I Made One Million Dollars … Last Year …
Trading Commodities*, Windsor Books, 1973 — the original %R
publication.
## See also
- [Indicator: Stochastic](Indicator-Stochastic.md) — the positive-axis
sibling; `%R` and `%K` are linked by `%R = %K 100`.
- [Indicator: Rsi](Indicator-Rsi.md) — slower bounded oscillator,
better behaved in trending markets.
- [Warmup Periods](../../Warmup-Periods.md) — bare `period` (no off-by-one).
@@ -0,0 +1,214 @@
# DEMA
> Double Exponential Moving Average — Patrick Mulloy's `2·EMA EMA(EMA)`,
> a single-line trend filter that removes the first-order lag of a plain
> EMA.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Exponential family |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required (no default in either binding) |
| Warmup period | `2·period 1` |
| Interpretation | EMA-style smoothing with less lag; sits ahead of `Ema` on a sustained trend. |
## Formula
Let `EMA1 = EMA(price, period)` and `EMA2 = EMA(EMA1, period)`. Then:
```
DEMA_t = 2 * EMA1_t - EMA2_t
```
Both inner EMAs use the same `period`, hence the same
`α = 2 / (period + 1)`. The subtraction is a finite-difference
approximation of "remove the lag introduced by single EMA smoothing":
if EMA lags the true series by `L`, then EMA(EMA) lags by roughly `2L`,
so `2·EMA EMA(EMA)` cancels most of the first-order error.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|---------|-------------|-------------|
| `period` | `usize` | none | `>= 1` | Period shared by both internal EMAs. `period = 0` errors with `Error::PeriodZero`. |
(Python class `wickra.DEMA(period)` has no `#[pyo3(signature)]` default;
pass `period` explicitly.)
## Inputs / Outputs
From `crates/wickra-core/src/indicators/dema.rs`:
```rust
impl Indicator for Dema {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
Python `update` returns `float | None`, `batch` returns a 1-D
`numpy.ndarray` (`float64`, `NaN` for warmup). Node `update` returns
`number | null`, `batch` returns `Array<number>` with `NaN` placeholders.
## Warmup
`Dema::new(period).warmup_period() == 2 * period - 1`. The comment in
the source explains it cleanly:
> EMA1 seeds at `period`, then EMA2 needs another `period 1` values to
> seed.
`Ema::new(period)` only starts producing output once it has seen
`period` inputs. So `ema1` emits its first value at input `period`. From
that point on, `ema2` starts receiving inputs (the outputs of `ema1`)
and itself needs `period` of them to seed — first emission at "input
`period` of `ema1`" = input `2·period 1` of `Dema`. For
`Dema::new(14)` this gives `27`, matching the table in
[Warmup Periods](../../Warmup-Periods.md).
The implementation uses the `?` operator to short-circuit:
`let e1 = self.ema1.update(input)?; let e2 = self.ema2.update(e1)?;`,
so `ema2` is only fed once `ema1` actually emits — which is exactly
what the warmup arithmetic above models.
## Edge cases
- **Constant series.** Feeding `[100.0; n]` eventually produces
`Some(100.0)`: once both EMAs converge to `100.0`, the output is
`2 · 100 100 = 100`. The unit test `constant_series_yields_constant_dema`
pins this with `Dema::new(5)` over 60 constants.
- **NaN / infinity inputs.** Inherited from the inner `Ema`: non-finite
inputs are silently dropped and the previously emitted value (if any)
is preserved. Inputs that fail to pass `is_finite()` never reach the
`2·EMA1 EMA2` arithmetic.
- **Reset.** `dema.reset()` resets both internal EMAs. The next `update`
starts a full `2·period 1` warmup countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Dema, Indicator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut dema = Dema::new(5)?;
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let out: Vec<Option<f64>> = dema.batch(&prices);
println!("warmup_period = {}", dema.warmup_period());
println!("{:?}", out);
Ok(())
}
```
Output:
```
warmup_period = 9
[None, None, None, None, None, None, None, None, Some(9.0), Some(10.0), Some(11.0), Some(12.0), Some(13.000000000000002), Some(14.000000000000002), Some(15.000000000000002), Some(16.000000000000004), Some(17.0), Some(18.0), Some(19.0), Some(20.0)]
```
The first `Some` arrives at index 8 (the 9th input), exactly as
predicted by `2·5 1 = 9`. On a linear ramp `1, 2, …, 20`, DEMA tracks
the input ramp almost perfectly because the lag has been cancelled to
first order — the floating-point tail of `13.000000000000002` is
ordinary IEEE-754 drift. The unit test
`linear_uptrend_dema_above_ema_eventually` pins the property that
`Dema` exceeds `Ema` of the same period on a sustained uptrend.
### Python
```python
import numpy as np
import wickra as ta
dema = ta.DEMA(5)
out = dema.batch(np.arange(1.0, 21.0))
print("warmup_period =", dema.warmup_period())
print(out)
```
Output:
```
warmup_period = 9
[nan nan nan nan nan nan nan nan 9. 10. 11. 12. 13. 14. 15. 16. 17. 18.
19. 20.]
```
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const dema = new ta.DEMA(5);
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
console.log(dema.batch(prices));
console.log('warmupPeriod:', dema.warmupPeriod());
```
Output:
```
[
NaN, NaN,
NaN, NaN,
NaN, NaN,
NaN, NaN,
9, 10,
11, 12,
13.000000000000002, 14.000000000000002,
15.000000000000002, 16.000000000000004,
17, 18,
19, 20
]
warmupPeriod: 9
```
## Interpretation
`Dema` is the canonical "I want EMA, but with less lag" answer. On a
sustained directional trend the DEMA line sits ahead of an `Ema` of the
same period (the unit test pins this). The same signals you use for
`Ema` — price-vs-MA crossover, fast-vs-slow MA crossover — apply, and
they fire earlier. In return for the lower lag you accept more
sensitivity to noise: on choppy data DEMA will whipsaw earlier than EMA
of the same period.
Prefer `Dema` over `Ema` when you want a faster trend filter without
moving to a smaller `period` (which would also amplify noise). Prefer
`Tema` for *even* less lag at the cost of further noise sensitivity, or
`Hma` if you want lag reduction *plus* an inherent smoothing step.
## Common pitfalls
- **Picking a `period` that's too short for a noisy market.** Because
`Dema` removes lag rather than adding smoothing, on choppy series it
amplifies high-frequency oscillations. If you reach for `Dema(5)` on
a tick-by-tick feed and get a jittery line, the fix is to *raise*
`period``Dema(20)` is often a better compromise than `Dema(5)`.
- **Assuming the first `Dema` value lines up with the first `Ema`
value at the same period.** `Ema(14)` first emits at input 14;
`Dema(14)` first emits at input 27. If you align a DEMA series to an
EMA series in a backtest, account for the offset or use the
`~np.isnan(...)` mask (Python) / `is_some()` filter (Rust) to drop the
warmup rows.
## References
Patrick G. Mulloy, *"Smoothing Data with Faster Moving Averages"*,
**Technical Analysis of Stocks & Commodities**, January 1994 (DEMA), and
*"Smoothing Data with Less Lag"*, **Technical Analysis of Stocks &
Commodities**, February 1994 (TEMA).
## See also
- [Indicator-Ema.md](Indicator-Ema.md) — the building block.
- [Indicator-Tema.md](Indicator-Tema.md) — three-EMA version, less lag still.
- [Indicator-Hma.md](Indicator-Hma.md) — same lag-reduction goal, built on WMAs.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
+201
View File
@@ -0,0 +1,201 @@
# EMA
> Exponential Moving Average with smoothing factor `α = 2 / (period + 1)`,
> seeded from the SMA of the first `period` inputs (the TA-Lib convention).
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Exponential family |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required; or `Ema::with_alpha(α)` for a custom smoothing factor |
| Warmup period | `period` |
| Interpretation | Smoother, less laggy than `Sma` of the same length. |
## Formula
For `t >= period` (after warmup):
```
α = 2 / (period + 1)
seed = (1 / period) * Σ_{i=0}^{period-1} price_i // SMA of first `period` inputs
EMA_t = α * price_t + (1 - α) * EMA_{t-1} // recursive update
```
The first emitted value (at input `period`) is the seed itself, identical
to `Sma::new(period)` on the same prefix. From input `period + 1` onward
the recursive formula takes over. (`Ema::with_alpha(α)` skips the seed and
uses the very first input as the initial state, so `warmup_period() == 1`
in that mode — see the `with_alpha` method for details.)
Wilder's smoothing (used by `Rsi`/`Atr`/`Adx`) uses `α = 1/period`
instead; that is a different smoothing constant and a different
indicator family.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|----------|---------|-------------|-------------|
| `period` | `usize` | none | `>= 1` | Window length used to derive `α`. `period = 0` errors with `Error::PeriodZero`. |
| `α` (alternative constructor `Ema::with_alpha`) | `f64` | none | `(0.0, 1.0]` and finite | Custom smoothing factor; bypasses the period-derived α. Reported `period` is 1, `warmup_period() == 1`. Invalid `α` errors with `Error::InvalidPeriod`. |
(The Python class `wickra.EMA(period)` does not set a `#[pyo3(signature)]`
default; the period must be passed explicitly. `with_alpha` is Rust-only.)
## Inputs / Outputs
From `crates/wickra-core/src/indicators/ema.rs`:
```rust
impl Indicator for Ema {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
Python streams as `float | None`, batches as a 1-D `numpy.ndarray`
(`NaN` for warmup). Node streams as `number | null`, batches as
`Array<number>` with `NaN` placeholders.
## Warmup
`Ema::new(period).warmup_period() == period`. The first non-empty value
is the SMA of the first `period` inputs (the "seed"); from there each
new input contributes `α * input + (1 α) * previous`. The unit test
`warmup_returns_none_until_seed` and the test
`first_value_equals_sma_seed` pin this contract.
This is the same warmup count as `Sma::new(period)` because the seed
itself is an SMA — `Ema` is "no slower to start emitting than `Sma`, just
more reactive afterwards".
## Edge cases
- **Constant series.** Feeding `[42.0; n]` returns `Some(42.0)` from input
`period` onward; the seed is `42.0`, and `α · 42 + (1 α) · 42 = 42`.
The unit test `constant_series_converges_to_constant` pins this.
- **NaN / infinity inputs.** The first line of `update` is
`if !input.is_finite() { return self.state; }`. Non-finite inputs are
silently dropped: they do not advance warmup, do not corrupt the
state, and the previously emitted value (if any) is returned. The unit
test `ignores_non_finite_input` pins this.
- **Reset.** `ema.reset()` clears both the smoothed state and the warmup
buffer; the next `update` starts a new warmup countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Ema, Indicator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut ema = Ema::new(3)?;
let out: Vec<Option<f64>> = ema.batch(&[1.0, 2.0, 3.0, 10.0]);
println!("{:?}", out);
println!("alpha = {}", ema.alpha());
Ok(())
}
```
Output:
```
[None, None, Some(2.0), Some(6.0)]
alpha = 0.5
```
`period = 3` gives `α = 2 / 4 = 0.5`. The seed at input 3 is the SMA of
`[1, 2, 3] = 2.0`; the next step is `0.5 · 10 + 0.5 · 2 = 6.0`. This
matches the `step_after_seed_uses_alpha_formula` unit test.
### Python
```python
import wickra as ta
ema = ta.EMA(3)
for x in [1.0, 2.0, 3.0, 10.0]:
print(x, '->', ema.update(x))
print('alpha:', ema.alpha)
print('warmup_period:', ema.warmup_period())
```
Output:
```
1.0 -> None
2.0 -> None
3.0 -> 2.0
10.0 -> 6.0
alpha: 0.5
warmup_period: 3
```
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const ema = new ta.EMA(3);
for (const x of [1, 2, 3, 10]) {
console.log(x, '->', ema.update(x));
}
```
Output:
```
1 -> null
2 -> null
3 -> 2
10 -> 6
```
## Interpretation
`Ema` is the "default" smoothed trend filter for most practitioners. The
two main signals are price-vs-EMA and EMA-fast-vs-EMA-slow crossovers
(the latter is the basis of `MacdIndicator`). Compared with `Sma` at the
same period, `Ema` reacts faster to direction changes at the cost of
slightly noisier output — useful when you care about the inflection
point, not the long-run level.
Prefer `Ema` over `Sma` when you want a single-line trend filter with
moderate lag. Prefer `Dema` / `Tema` when the EMA lag is too much for
your timeframe. Prefer `Hma` when you want lag reduction *and* a built-in
noise filter (a triple WMA chain rather than a triple EMA chain).
## Common pitfalls
- **Confusing `α = 2/(n+1)` with Wilder's `α = 1/n`.** Wickra's `Ema`
uses the TA-Lib convention `α = 2/(n+1)`. The same numerical period
passed to `Rsi(14)` or `Atr(14)` uses `α = 1/14 ≈ 0.0714`, not
`α = 2/15 ≈ 0.1333`. They are different smoothing schemes; comparing
an EMA(14) line directly to the RSI/ATR's internal smoothing will not
match. If you want a Wilder-style EMA, build it on top of `Ema` with
the custom factor: `Ema::with_alpha(1.0 / 14.0)`.
- **Assuming the first emitted EMA is "the EMA".** The first value is
the SMA seed, not a recursively-smoothed EMA. The series only starts
behaving like an EMA from input `period + 1` onward. For short series,
this means the first emission tracks `Sma::new(period)` exactly — that
is the intended behaviour, not a bug.
## References
The TA-Lib seeding convention used here ("EMA is seeded with an SMA")
is documented in the TA-Lib source and replicated by virtually every
commercial charting platform. The recursive form
`EMA_t = α · price + (1 α) · EMA_{t-1}` is the standard exponential
smoothing identity attributed to Robert Brown (1956).
## See also
- [Indicator-Sma.md](Indicator-Sma.md) — equal weights, identical seed.
- [Indicator-Dema.md](Indicator-Dema.md) — `2·EMA EMA(EMA)`.
- [Indicator-Tema.md](Indicator-Tema.md) — `3·EMA 3·EMA(EMA) + EMA(EMA(EMA))`.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
+242
View File
@@ -0,0 +1,242 @@
# HMA
> Hull Moving Average — Alan Hull's
> `WMA(2·WMA(n/2) WMA(n), √n)`, a near-lag-free trend filter that
> combines a fast `Wma(n/2)`, a slow `Wma(n)`, and a final smoothing pass.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Adaptive & hybrid |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required (no default in either binding) |
| Warmup period (`warmup_period()`) | `period + round(√period).max(1) 1` — see below; the practical first-emission index can lag this number |
| Interpretation | Near-zero-lag trend line with an inherent smoothing step. |
## Formula
```
half = max(period / 2, 1) // integer division
smooth = max(round(sqrt(period)), 1) // nearest integer, floor at 1
raw_t = 2 * WMA(price, half)_t - WMA(price, period)_t
HMA_t = WMA(raw, smooth)_t
```
The "magic" is the `2·WMA(n/2) WMA(n)` step: the fast WMA leads the
slow WMA on a trend, so doubling the fast and subtracting the slow
produces a series that is *ahead* of the input by roughly the WMA lag.
The final `WMA(…, √n)` then smooths the resulting overshoot back down
to a clean line. For `period = 9` this gives `half = 4`, `smooth = 3`;
for `period = 14`, `half = 7`, `smooth = 4`.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|---------|-------------|-------------|
| `period` | `usize` | none | `>= 1` | Top-level lookback. The inner WMA periods are derived from it. `period = 0` errors with `Error::PeriodZero`. |
(Python class `wickra.HMA(period)` has no `#[pyo3(signature)]` default;
pass `period` explicitly.)
## Inputs / Outputs
From `crates/wickra-core/src/indicators/hma.rs`:
```rust
impl Indicator for Hma {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
Python returns `float | None` (streaming) / `numpy.ndarray` (batch,
`NaN` for warmup). Node returns `number | null` / `Array<number>` with
`NaN`.
## Warmup
This is the one case in the trend family where the reported
`warmup_period()` is a **lower bound**, not the exact first-emission
index.
The `warmup_period()` method returns:
```
period + round(sqrt(period)).max(1) - 1
```
which gives `11` for `Hma::new(9)`, `17` for `Hma::new(14)`,
`19` for `Hma::new(16)`. This number assumes the three inner WMAs
warm up *in parallel*: the slow `WMA(period)` would emit at input
`period`, and the smoothing `WMA(√period)` would then need `√period 1`
more inputs.
In practice the implementation uses the `?` short-circuit:
```rust
fn update(&mut self, input: f64) -> Option<f64> {
let h = self.half_wma.update(input)?; // returns early if None
let f = self.full_wma.update(input)?; // ONLY called when half emits
let diff = 2.0 * h - f;
self.smooth_wma.update(diff)
}
```
`self.full_wma.update(input)` is only reached after `self.half_wma`
starts emitting (i.e. from input `half = period/2` onward). So
`full_wma` does not see input until iteration `half`, and then needs
`period` of its own inputs — it emits first at iteration
`half + period 1`. The diff then flows into `smooth_wma`, which needs
`smooth` of those — first emission at iteration
`half + period - 1 + smooth - 1` = `half + period + smooth 2`.
For the three example periods this gives:
| `period` | `half` | `smooth` | `warmup_period()` (reported) | Actual first emission |
|----------|--------|----------|------------------------------|------------------------|
| 9 | 4 | 3 | 11 | 14 |
| 14 | 7 | 4 | 17 | 23 |
| 16 | 8 | 4 | 19 | 26 |
The numbers in the "Actual first emission" column are verified by
streaming `Hma::new(period).update(...)` over a linear ramp and noting
the first call that returns `Some`. The discrepancy is a known
implementation quirk: the reported value is the theoretical floor; the
streaming order pushes the practical emission later. If you need the
exact first-non-`None` index for chaining or array alignment, prefer
checking `is_ready()` or filtering on `~np.isnan(...)` after the fact.
## Edge cases
- **Constant series.** Feeding `[10.0; n]` produces `Some(10.0)` once
the chain is warm. All three WMAs converge to `10`, so
`raw = 2·10 10 = 10`, then `WMA(10, smooth) = 10`. The unit test
`constant_series_yields_constant_hma` pins this with `Hma::new(9)`
over 80 constants.
- **NaN / infinity inputs.** Inherited from the inner `Wma`: non-finite
inputs are silently dropped at the half/full WMA boundary and never
reach the `2·h f` arithmetic.
- **Reset.** `hma.reset()` resets all three internal WMAs; the next
`update` starts a full warmup countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Hma, Indicator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut hma = Hma::new(9)?;
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let out: Vec<Option<f64>> = hma.batch(&prices);
println!("warmup_period (reported) = {}", hma.warmup_period());
println!("{:?}", out);
Ok(())
}
```
Output:
```
warmup_period (reported) = 11
[None, None, None, None, None, None, None, None, None, None, None, None, None, Some(14.0), Some(15.0), Some(16.0), Some(17.0), Some(18.0), Some(19.0), Some(20.0)]
```
The reported warmup says `11`, but the first `Some` lands at index 13
(the 14th input) for the reason given in the [Warmup](#warmup) section.
On the linear ramp `1, 2, …, 20`, HMA tracks price exactly with no
visible lag.
### Python
```python
import numpy as np
import wickra as ta
hma = ta.HMA(9)
out = hma.batch(np.arange(1.0, 21.0))
print("warmup_period (reported) =", hma.warmup_period())
print(out)
```
Output:
```
warmup_period (reported) = 11
[nan nan nan nan nan nan nan nan nan nan nan nan nan 14. 15. 16. 17. 18.
19. 20.]
```
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const hma = new ta.HMA(9);
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
console.log(hma.batch(prices));
console.log('warmupPeriod (reported):', hma.warmupPeriod());
```
Output:
```
[
NaN, NaN, NaN, NaN, NaN, NaN,
NaN, NaN, NaN, NaN, NaN, NaN,
NaN, 14, 15, 16, 17, 18,
19, 20
]
warmupPeriod (reported): 11
```
## Interpretation
`Hma` is the lag-reduction trend filter that does *not* require you to
choose between responsiveness and noise: the final `WMA(√period)` pass
is a built-in smoothing step that prevents the kind of whipsaw a `Tema`
of the same period would produce on noisy data. On clean trending data
it sits effectively on top of price; on choppy data the smoothing pass
keeps the line readable.
The textbook signal is colour-coded slope: HMA turning up = uptrend,
turning down = downtrend. Crossover patterns (`Hma(9)` vs `Hma(20)`)
also work and tend to be cleaner than the equivalent EMA pair.
Prefer `Hma` over `Dema` / `Tema` when your data is noisy enough that
the lag-reduction in those would manifest as whipsaws. Prefer `Tema` /
`Dema` on cleaner data where you want one fewer smoothing step.
## Common pitfalls
- **Trusting `warmup_period()` for chaining or array alignment.** As
the table above shows, `Hma::new(9).warmup_period() == 11` but the
first actual emission is at the 14th input. If you use HMA as the
first stage of a `Chain`, the chain's overall warmup will lag what
`Chain::warmup_period()` reports. Filter on `is_some()` /
`~np.isnan(...)` after the fact, or precompute the actual index by
streaming a small ramp once.
- **Picking `period = 2` or `3`.** The inner `half = period / 2` is an
integer division floored at 1. For `period = 2`, `half = 1`,
`smooth = 1`, and you essentially end up with `Wma(2·price WMA(2))`
which is a sharp, noisy line. HMA is designed for `period >= 9` or so;
for shorter lookbacks reach for `Ema(period)` or `Wma(period)` instead.
## References
Alan Hull, *"How to Reduce Lag in a Moving Average"*, 2005 — the
original HMA derivation, hosted on Hull's site at
<https://alanhull.com/hull-moving-average>.
## See also
- [Indicator-Wma.md](Indicator-Wma.md) — the building block.
- [Indicator-Tema.md](Indicator-Tema.md) — same lag-reduction goal, EMA-based.
- [Indicator-Kama.md](Indicator-Kama.md) — adaptive smoothing instead of fixed.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,251 @@
# KAMA
> Kaufman's Adaptive Moving Average — picks its own smoothing constant
> on every bar from a fast/slow EMA pair, weighted by an efficiency
> ratio that measures how trending the recent price action has been.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Adaptive & hybrid |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | Python: `(er_period=10, fast=2, slow=30)`; Rust: `Kama::classic()` returns the same triple |
| Warmup period (`warmup_period()`) | `er_period + 1` — see below; the *first* emission lands at this index, but on a fresh KAMA that emission equals the seed (the input itself) |
| Interpretation | Fast in trending markets, slow in choppy markets — by construction. |
## Formula
For each new input `price_t` (with `n = er_period`):
```
direction_t = | price_t - price_{t-n} |
volatility_t = Σ_{i=1}^{n} | price_{t-i+1} - price_{t-i} |
ER_t = direction_t / volatility_t // 0 = pure chop, 1 = pure trend; 0 if volatility = 0
fast_sc = 2 / (fast + 1) // fast EMA smoothing constant
slow_sc = 2 / (slow + 1) // slow EMA smoothing constant
SC_t = (ER_t * (fast_sc - slow_sc) + slow_sc) ^ 2
KAMA_t = KAMA_{t-1} + SC_t * (price_t - KAMA_{t-1})
```
The squared `SC_t` is Kaufman's choice (he found that squaring widens
the dynamic range between "act like a fast EMA" and "act like a slow
EMA"). On the very first emission `KAMA_{t-1}` is seeded with the
oldest price in the window (`window.front()`), which is the convention
in the source.
## Parameters
| Name | Type | Default (Python `KAMA(...)`) | Valid range | Description |
|-------------|---------|-------------------------------|-------------|-------------|
| `er_period` | `usize` | `10` | `>= 1` | Lookback for the efficiency ratio. Larger → smoother ER, slower adaptation. |
| `fast` | `usize` | `2` | `>= 1`, strictly `< slow` | Fast EMA period; sets the lower bound on responsiveness. |
| `slow` | `usize` | `30` | `>= 1`, strictly `> fast` | Slow EMA period; sets the upper bound on smoothness. |
Any of `er_period`, `fast`, `slow` being `0` errors with
`Error::PeriodZero`; `fast >= slow` errors with `Error::InvalidPeriod`.
The Python defaults come from
`#[pyo3(signature = (er_period=10, fast=2, slow=30))]` in
`bindings/python/src/lib.rs`; the Rust convenience constructor
`Kama::classic()` returns the same triple.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/kama.rs`:
```rust
impl Indicator for Kama {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
Python returns `float | None` (streaming) / `numpy.ndarray` (batch,
`NaN` for warmup). Node returns `number | null` (streaming) /
`Array<number>` with `NaN` (batch). `warmup_period()` is exposed in
Rust and Python but **not** on the Node `KAMA` class (consult
`bindings/node/index.d.ts` for the surface).
## Warmup
`Kama::new(er_period, fast, slow).warmup_period() == er_period + 1`.
The "off-by-one" is because the efficiency ratio compares `price_t` to
`price_{t-er_period}` and sums `er_period` consecutive absolute diffs;
that requires `er_period + 1` prices in the window. For
`Kama::classic()` (`er_period = 10`) the first emission lands on input
11, matching the table in [Warmup Periods](../../Warmup-Periods.md).
The implementation uses a `VecDeque` of capacity `er_period + 1`. Once
full, every subsequent `update` pops the front and pushes the new
input — `update` is O(`er_period`) in principle (the volatility sum is
re-computed) but O(1) in `period`/`fast`/`slow` since the EMA-style
recursion has no window.
Note: on the *first* emission, `prev = window.front()` (the oldest
price), and the output is
`prev + SC · (input prev)`. On a perfectly trending series
(`ER ≈ 1`, `SC ≈ fast_sc² ≈ 0.444`) this means the first KAMA value
is materially below the latest price; on `[1, 2, …, 20]` for instance,
KAMA's first emission at input 11 is `5.444…`, not `11`. See the
example output below.
## Edge cases
- **Constant series.** Feeding `[100.0; n]` produces `Some(100.0)`:
both `direction` and `volatility` are zero, the source branches
`if volatility == 0.0 { 0.0 } else { ... }` so `ER = 0`,
`SC = slow_sc² ≈ 0.00416`, and
`100 + 0.00416 · (100 100) = 100`. The unit test
`constant_series_yields_constant_kama` pins this with
`Kama::classic()`.
- **NaN / infinity inputs.** The first line of `update` is
`if !input.is_finite() { return self.state; }`. Non-finite inputs are
silently dropped; the window is not advanced, the previously emitted
value is preserved.
- **Reset.** `kama.reset()` clears both the window and the smoothed
state. The next `update` starts a fresh `er_period + 1` warmup
countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Kama};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut kama = Kama::classic(); // (10, 2, 30)
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let out: Vec<Option<f64>> = kama.batch(&prices);
println!("warmup_period = {}", kama.warmup_period());
println!("{:?}", out);
Ok(())
}
```
Output:
```
warmup_period = 11
[None, None, None, None, None, None, None, None, None, None, Some(5.444444444444443), Some(8.358024691358022), Some(10.421124828532234), Some(12.011736015851241), Some(13.339853342139579), Some(14.522140745633099), Some(15.62341152535172), Some(16.679673069639843), Some(17.710929483133246), Some(18.728294157296247)]
```
`Kama::classic().periods()` returns `(10, 0.6666666666666666, 0.06451612903225806)`
— the second and third numbers are `fast_sc = 2/3` and `slow_sc = 2/31`,
not the integer `fast`/`slow` periods themselves. On the linear ramp
`1, 2, …, 20` the efficiency ratio is `1.0` (every step moves direction
the same as volatility), so `SC = fast_sc² ≈ 0.4444`. The first
emission `5.444…` is `1 + 0.4444 · (11 1)` and each subsequent value
follows the same recursion.
### Python
```python
import numpy as np
import wickra as ta
kama = ta.KAMA() # defaults: er_period=10, fast=2, slow=30
out = kama.batch(np.arange(1.0, 21.0))
print("warmup_period =", kama.warmup_period())
print(out)
```
Output:
```
warmup_period = 11
[ nan nan nan nan nan nan
nan nan nan nan 5.44444444 8.35802469
10.42112483 12.01173602 13.33985334 14.52214075 15.62341153 16.67967307
17.71092948 18.72829416]
```
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const kama = new ta.KAMA(10, 2, 30); // no default constructor; pass the triple
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
console.log(kama.batch(prices));
```
Output:
```
[
NaN, NaN,
NaN, NaN,
NaN, NaN,
NaN, NaN,
NaN, NaN,
5.444444444444443, 8.358024691358022,
10.421124828532234, 12.011736015851241,
13.339853342139579, 14.522140745633099,
15.62341152535172, 16.679673069639843,
17.710929483133246, 18.728294157296247
]
```
(The Node `KAMA` class does not expose `warmupPeriod()`; use the Rust
or Python binding if you need that getter from your application.)
## Interpretation
KAMA's defining property is that it **changes its own behaviour with the
market**. In a clean trend the efficiency ratio approaches `1`, `SC`
approaches `fast_sc²`, and KAMA behaves like a fast EMA — it tracks
price closely. In a choppy sideways market the efficiency ratio
collapses toward `0`, `SC` approaches `slow_sc²`, and KAMA effectively
freezes — its line goes nearly flat regardless of how violently price
oscillates around it. This is by design: Kaufman's argument is that you
should not chase noise.
The two usable signals are slope (positive = uptrend; flat = ranging;
negative = downtrend) and price-vs-KAMA crossover. Because KAMA can
sit nearly flat for long stretches in a range, "price crossed KAMA"
generates fewer false signals than the same test against an EMA of
similar period.
Prefer `Kama` over a static EMA/SMA when the market regime varies
materially (trending → ranging → trending). Prefer a fixed `Ema` /
`Hma` when you want a predictable smoothing profile that is independent
of price action.
## Common pitfalls
- **Assuming `warmup_period()` is when the line is "good".** The first
emission lands at input 11 (for the default `er_period = 10`), but
the seed `KAMA_{t-1} = window.front()` is the *oldest* price in the
window, so the very first emitted value is biased toward the
10-bars-ago price. On a strong trend this means the first 35
emissions are noticeably below (or above, depending on direction) the
current price. If that matters, drop the first `er_period` post-warmup
emissions, not just the warmup itself.
- **Tuning `fast` and `slow` independently of `er_period`.** Kaufman's
derivation assumes `slow >> fast` so that the per-bar SC has room to
move. Picking, say, `(10, 5, 6)` gives `fast_sc ≈ 0.333` and
`slow_sc ≈ 0.286`, so SC barely changes regardless of the efficiency
ratio — KAMA degenerates into "an EMA somewhere around period 6".
Keep `slow` at least `5×` `fast` if you want the adaptive behaviour
to actually matter.
## References
Perry J. Kaufman, *Smarter Trading*, McGraw-Hill, 1995 (book-length
introduction); reprinted in Kaufman's *Trading Systems and Methods*
across multiple editions, where the squared-SC choice is justified
empirically.
## See also
- [Indicator-Ema.md](Indicator-Ema.md) — the two endpoints (`fast` and
`slow`) KAMA interpolates between.
- [Indicator-Hma.md](Indicator-Hma.md) — the other "smart" trend filter in
Wickra.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
+184
View File
@@ -0,0 +1,184 @@
# SMA
> Simple Moving Average — the equal-weighted rolling mean of the last
> `period` closes, maintained as an O(1) rolling-sum state machine.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Simple averages |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required (no default in either binding) |
| Warmup period | `period` |
| Interpretation | Smoothed price level; price-vs-SMA crossings flag direction changes. |
## Formula
```
SMA_t = (1 / n) * Σ_{i=0}^{n-1} price_{t-i}
```
where `n = period`. Maintained incrementally as `sum -= window.pop_front();
sum += new_price; out = sum / n`, so `update` is O(1) regardless of
`period`.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|----------|---------|-------------|-------------|
| `period` | `usize` | none | `>= 1` | Length of the rolling window. `period = 0` errors with `Error::PeriodZero`. `period = 1` is a pass-through. |
(There is no Python `#[pyo3(signature = …)]` default for `SMA`, so
`wickra.SMA(period)` requires the period explicitly.)
## Inputs / Outputs
From `crates/wickra-core/src/indicators/sma.rs`:
```rust
impl Indicator for Sma {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
A single `f64` close in, an `Option<f64>` out. The Python binding maps
this to `float | None` (streaming) or a `numpy.ndarray` of dtype
`float64` with `NaN` for warmup rows (batch). The Node binding maps it to
`number | null` / `Array<number>` with `NaN` for warmup.
## Warmup
`Sma::new(period).warmup_period() == period`. The first non-empty value
is emitted on the `period`-th `update()` call, because the window needs to
hold exactly `period` values before the mean is defined. There is no
seeding step beyond filling the window — `Sma` only ever stores its
running sum and the `VecDeque` of values, so its readiness condition is
literally `window.len() == period`.
## Edge cases
- **Constant series.** Feeding `[7.0; n]` returns `Some(7.0)` from input
`period` onward; the running-sum bookkeeping is exact for constants
(the unit test `constant_series_yields_constant_sma` pins this).
- **NaN / infinity inputs.** The first line of `update` is
`if !input.is_finite() { return self.value(); }`. Non-finite inputs are
**silently dropped** — they do not advance the window, do not corrupt
the sum, and the previous valid value (if any) is returned. The unit
test `ignores_non_finite_input_but_keeps_state` pins this behaviour.
- **Reset.** `sma.reset()` clears the window and the sum, returning the
indicator to a fresh `is_ready() == false` state. The next `update`
starts a new warmup countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Sma};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut sma = Sma::new(3)?;
let out: Vec<Option<f64>> = sma.batch(&[2.0, 4.0, 6.0, 8.0, 10.0]);
println!("{:?}", out);
println!("warmup_period = {}", sma.warmup_period());
Ok(())
}
```
Output:
```
[None, None, Some(4.0), Some(6.0), Some(8.0)]
warmup_period = 3
```
The first two inputs return `None` while the window fills; the third
emits `(2 + 4 + 6) / 3 = 4.0` and every subsequent input slides the
window by one. This matches the `known_reference_values` test in
`crates/wickra-core/src/indicators/sma.rs`.
### Python
```python
import numpy as np
import wickra as ta
sma = ta.SMA(3)
print(sma.batch(np.array([2.0, 4.0, 6.0, 8.0, 10.0])))
print("warmup_period =", sma.warmup_period())
```
Output:
```
[nan nan 4. 6. 8.]
warmup_period = 3
```
Warmup rows come back as `NaN` so the result aligns 1:1 with the input
array.
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const sma = new ta.SMA(3);
console.log(sma.batch([2, 4, 6, 8, 10]));
console.log('warmupPeriod:', sma.warmupPeriod());
```
Output:
```
[ NaN, NaN, 4, 6, 8 ]
warmupPeriod: 3
```
## Interpretation
`Sma` is a smoothed price level. The two canonical signals are:
1. **PriceSMA crossover.** Close above the SMA suggests an uptrend, close
below suggests a downtrend. The longer the SMA, the slower (and more
trustworthy) the signal.
2. **Two-SMA crossover.** A fast SMA crossing above a slow SMA is the
classic "golden cross"; below is the "death cross". Either of `Ema`
or `Hma` will give earlier (but noisier) signals at the same period.
Prefer `Sma` when you want the simplest possible reference price — for
example, as the middle band of [`BollingerBands`](../../Indicators-Overview.md),
which uses an SMA by construction. Prefer `Ema` if you want the same
smoothness profile but slightly less lag on direction changes.
## Common pitfalls
- **Treating `period = 0` as "use a default".** `Sma::new(0)` returns
`Err(Error::PeriodZero)` in Rust and a `ValueError` in Python; there is
no implicit default. Pass an explicit period.
- **Slicing batch results with `> warmup_period` instead of
`~np.isnan(...)`.** In Python the batch output has `NaN` for warmup
rows; in Rust it has `None`. Use the warmup-aware mask to filter — see
the [Quickstart: Python](../../Quickstart-Python.md#macd-a-multi-column-indicator-and-its-warmup-nans)
pattern. Slicing by `prices.size - warmup_period` works for a single
indicator but breaks the moment you compose two of them via `Chain`.
## References
The simple moving average predates technical analysis as a discipline.
The implementation here follows the standard "rolling sum, slide on each
update" formulation; the matching reference implementations are TA-Lib
and pandas (`rolling(period).mean()`).
## See also
- [Indicator-Ema.md](Indicator-Ema.md) — same smoothness budget, less lag.
- [Indicator-Wma.md](Indicator-Wma.md) — linear weights instead of equal.
- [Indicator-Hma.md](Indicator-Hma.md) — built on three WMAs for near-zero
lag.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,208 @@
# TEMA
> Triple Exponential Moving Average — Mulloy's
> `3·EMA1 3·EMA2 + EMA3` (where each EMA is fed from the previous one),
> the second-order lag-reduction sibling of DEMA.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Exponential family |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required (no default in either binding) |
| Warmup period | `3·period 2` |
| Interpretation | Even less lag than `Dema`, at the cost of more noise sensitivity. |
## Formula
Let `EMA1 = EMA(price, period)`, `EMA2 = EMA(EMA1, period)`,
`EMA3 = EMA(EMA2, period)`. Then:
```
TEMA_t = 3 * EMA1_t - 3 * EMA2_t + EMA3_t
```
All three EMAs share the same `period`, hence the same
`α = 2 / (period + 1)`. The coefficients `(3, 3, 1)` are the
second-order finite-difference correction that removes both the
first-order and second-order EMA lag terms — they come from expanding
`(1 L)^{-3}` where `L` is the lag operator.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|---------|-------------|-------------|
| `period` | `usize` | none | `>= 1` | Period shared by all three internal EMAs. `period = 0` errors with `Error::PeriodZero`. |
(Python class `wickra.TEMA(period)` has no `#[pyo3(signature)]` default;
pass `period` explicitly.)
## Inputs / Outputs
From `crates/wickra-core/src/indicators/tema.rs`:
```rust
impl Indicator for Tema {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
Python `update` returns `float | None`, `batch` returns a 1-D
`numpy.ndarray` (`float64`, `NaN` for warmup). Node `update` returns
`number | null`, `batch` returns `Array<number>` with `NaN`
placeholders.
## Warmup
`Tema::new(period).warmup_period() == 3 * period - 2`. Each stacked EMA
adds `period 1` more inputs to the warmup count:
- `ema1` emits first at input `period`.
- `ema2`, fed from `ema1`, emits first at input `period + (period 1) = 2·period 1`.
- `ema3`, fed from `ema2`, emits first at input `(2·period 1) + (period 1) = 3·period 2`.
For `Tema::new(14)` this gives `40` (matches the table in
[Warmup Periods](../../Warmup-Periods.md)); for `Tema::new(5)` (the example
below) it gives `13`. The implementation uses `?` short-circuit on every
stage, so each inner EMA is only fed once the previous one emits.
## Edge cases
- **Constant series.** Feeding `[42.0; n]` produces `Some(42.0)` once all
three EMAs have converged: `3·42 3·42 + 42 = 42`. The unit test
`constant_series_yields_constant_tema` pins this with `Tema::new(5)`
over 80 constants.
- **NaN / infinity inputs.** Inherited from the inner `Ema`: non-finite
inputs are silently dropped at the `ema1` boundary and never reach the
`3·EMA1 3·EMA2 + EMA3` arithmetic.
- **Reset.** `tema.reset()` resets all three internal EMAs; the next
`update` starts a full `3·period 2` warmup countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Tema};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut tema = Tema::new(5)?;
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let out: Vec<Option<f64>> = tema.batch(&prices);
println!("warmup_period = {}", tema.warmup_period());
println!("{:?}", out);
Ok(())
}
```
Output:
```
warmup_period = 13
[None, None, None, None, None, None, None, None, None, None, None, None, Some(13.0), Some(14.0), Some(15.000000000000002), Some(16.000000000000004), Some(17.000000000000007), Some(18.000000000000007), Some(19.000000000000007), Some(20.0)]
```
The first `Some` lands at index 12 (the 13th input), matching
`3·5 2 = 13`. On the linear ramp `1, 2, …, 20`, TEMA tracks the input
ramp essentially exactly because both first- and second-order lag have
been cancelled; the floating-point tail
(`15.000000000000002`, `16.000000000000004`, …) is ordinary IEEE-754
drift from the recursive subtractions.
### Python
```python
import numpy as np
import wickra as ta
tema = ta.TEMA(5)
out = tema.batch(np.arange(1.0, 21.0))
print("warmup_period =", tema.warmup_period())
print(out)
```
Output:
```
warmup_period = 13
[nan nan nan nan nan nan nan nan nan nan nan nan 13. 14. 15. 16. 17. 18.
19. 20.]
```
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const tema = new ta.TEMA(5);
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
console.log(tema.batch(prices));
console.log('warmupPeriod:', tema.warmupPeriod());
```
Output:
```
[
NaN, NaN,
NaN, NaN,
NaN, NaN,
NaN, NaN,
NaN, NaN,
NaN, NaN,
13, 14,
15.000000000000002, 16.000000000000004,
17.000000000000007, 18.000000000000007,
19.000000000000007, 20
]
warmupPeriod: 13
```
## Interpretation
`Tema` removes more lag than `Dema` and noticeably more than `Ema`.
On a clean trending series the line stays glued to price; on a noisy
or sideways series the same lag-cancellation amplifies the noise — TEMA
overshoots and reverses faster than DEMA, and very much faster than EMA.
The signals are the same crossover patterns: price-vs-TEMA and
fast-TEMA-vs-slow-TEMA. The `(3, 3, 1)` coefficient pattern is also
what makes `Trix` (also in this family) work — `Trix` is the percentage
change of `EMA3`, the triple-smoothed series.
Prefer `Tema` when `Dema` still feels too laggy and your data is clean
enough to tolerate the extra noise sensitivity. Prefer `Hma` if you want
a similar lag profile but with a built-in smoothing step (WMA chain
instead of EMA chain), which behaves more gracefully on noisy data.
## Common pitfalls
- **Forgetting the `3·period 2` warmup.** `Tema::new(50)` will not
emit until input 148. That is a significant chunk of any short-term
backtest. If you are running a side-by-side panel of indicators with
different warmups, filter rows on `~np.isnan(...)` (Python) /
`is_some()` (Rust) per indicator rather than picking one global
warmup cutoff.
- **Using TEMA for noisy intraday data without a smoothing step.** The
same lag-cancellation that makes TEMA attractive on clean data turns
into whipsaws on tick-by-tick feeds. Either raise `period` materially
or switch to `Hma`, which has a final WMA smoothing pass built in.
## References
Patrick G. Mulloy, *"Smoothing Data with Less Lag"*, **Technical Analysis
of Stocks & Commodities**, February 1994 (TEMA). The coefficient pattern
`(3, 3, 1)` for cancelling first- and second-order EMA lag is derived
in the same article.
## See also
- [Indicator-Ema.md](Indicator-Ema.md) — the building block.
- [Indicator-Dema.md](Indicator-Dema.md) — second-order's sibling.
- [Indicator-Hma.md](Indicator-Hma.md) — similar lag profile, built on WMAs.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
+186
View File
@@ -0,0 +1,186 @@
# WMA
> Weighted Moving Average with linear weights `1, 2, …, period`, so the
> most recent bar carries the most weight.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend |
| Sub-category | Simple averages |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded; tracks the input price scale |
| Default parameters | `period` is required (no default in either binding) |
| Warmup period | `period` |
| Interpretation | Front-weighted trend filter; faster than `Sma`, smoother than `Ema`. |
## Formula
```
weights = [1, 2, ..., n] // n = period
W = n * (n + 1) / 2 // sum of weights
WMA_t = (1 / W) * Σ_{i=0}^{n-1} (n - i) * price_{t-i}
= (1 / W) * (n * price_t + (n-1) * price_{t-1} + ... + 1 * price_{t-n+1})
```
Maintained in O(1) using the identity that, when sliding the window by
one, every retained element's weight drops by exactly one and the
newcomer enters at weight `n`:
```
new_weight_sum = old_weight_sum - old_value_sum + n * new_input
new_value_sum = old_value_sum - oldest_value + new_input
```
This is the bookkeeping in the steady-state branch of `update`; during
warmup the full `Σ weight·value` is computed once when the window first
fills.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|---------|-------------|-------------|
| `period` | `usize` | none | `>= 1` | Length of the rolling window. `period = 0` errors with `Error::PeriodZero`. `period = 1` is a pass-through. |
(The Python class `wickra.WMA(period)` does not set a `#[pyo3(signature)]`
default; pass the period explicitly.)
## Inputs / Outputs
From `crates/wickra-core/src/indicators/wma.rs`:
```rust
impl Indicator for Wma {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
Python returns `float | None` from `update` and a `numpy.ndarray`
(`float64`, `NaN` for warmup) from `batch`. Node returns `number | null`
and `Array<number>` (with `NaN` placeholders) respectively.
## Warmup
`Wma::new(period).warmup_period() == period`. Like `Sma`, the first
emission lands on the `period`-th `update()` call: the window needs
exactly `period` values for the weighted sum to be defined. There is no
seeding step beyond filling the window.
## Edge cases
- **Constant series.** For `[c; n]`, every element contributes `c · weight_i`
and the result is `c · ΣW / ΣW = c`. The proptest
`proptest_matches_naive` exercises this implicitly across many random
inputs; the textbook `period = 4` test confirms `WMA(4)` of
`[1, 2, 3, 4]` is exactly `(1·1 + 2·2 + 3·3 + 4·4) / 10 = 30 / 10 = 3.0`.
- **NaN / infinity inputs.** The first line of `update` is
`if !input.is_finite() { return self.value(); }`. Non-finite inputs are
silently dropped — they do not advance warmup, do not corrupt the
rolling sums, and the previously emitted value (if any) is returned.
- **Reset.** `wma.reset()` clears the window and both rolling sums; the
next `update` starts a new warmup countdown.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Wma};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut wma = Wma::new(4)?;
let out: Vec<Option<f64>> = wma.batch(&[1.0, 2.0, 3.0, 4.0]);
println!("{:?}", out);
println!("warmup_period = {}", wma.warmup_period());
Ok(())
}
```
Output:
```
[None, None, None, Some(3.0)]
warmup_period = 4
```
The fourth input emits `(1·1 + 2·2 + 3·3 + 4·4) / (1+2+3+4) = 30 / 10 = 3.0`.
This matches the `known_values_period_4` unit test in
`crates/wickra-core/src/indicators/wma.rs`.
### Python
```python
import numpy as np
import wickra as ta
wma = ta.WMA(4)
print(wma.batch(np.array([1.0, 2.0, 3.0, 4.0])))
print("warmup_period =", wma.warmup_period())
```
Output:
```
[nan nan nan 3.]
warmup_period = 4
```
### Node
```javascript
const ta = require('D:/Coding/Wickra/bindings/node');
const wma = new ta.WMA(4);
console.log(wma.batch([1, 2, 3, 4]));
console.log('warmupPeriod:', wma.warmupPeriod());
```
Output:
```
[ NaN, NaN, NaN, 3 ]
warmupPeriod: 4
```
## Interpretation
`Wma` sits between `Sma` and `Ema` on the lag/responsiveness spectrum:
because the most recent bar carries weight `n` (vs `1` for the oldest),
direction changes propagate faster than in `Sma`, but the smooth linear
decay produces less of the "exponential tail" overshoot you sometimes
see with `Ema`. The same two crossover signals (price-vs-WMA and
fast-WMA-vs-slow-WMA) apply.
The most important downstream use of `Wma` inside Wickra is `Hma`:
`Hma` is built entirely from three `Wma` instances (see
[Indicator-Hma.md](Indicator-Hma.md)).
## Common pitfalls
- **Mistaking linear weights for exponential ones.** A `Wma(20)` is *not*
an `Ema(20)`; the weights decay linearly `(20, 19, 18, …, 1)` rather
than geometrically, so very old bars still contribute (weight 1) where
in an EMA they would have decayed to near zero. If you want the
exponential decay, use `Ema`.
- **Comparing `Wma(period)` to a "WMA" from a different library and
finding the seed off.** Wickra's `Wma` has no separate seeding step —
it simply returns `None` until the window is full and then returns the
exact weighted mean from input `period` onward. Some libraries
pre-seed with a partial-window value; that is a different convention
and will produce different first-few-bar values.
## References
The linearly-weighted moving average is older than most named indicators
and has no single canonical citation; TA-Lib's `WMA` is the standard
reference implementation and matches Wickra's output bit-for-bit.
## See also
- [Indicator-Sma.md](Indicator-Sma.md) — equal weights instead of linear.
- [Indicator-Ema.md](Indicator-Ema.md) — exponential decay instead of linear.
- [Indicator-Hma.md](Indicator-Hma.md) — Hull MA, built from three WMAs.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,226 @@
# ATR (Average True Range)
> Wilder's volatility benchmark: an exponentially-smoothed average of the
> per-bar true range that absorbs overnight gaps and is dimensioned in price
> units.
## Quick reference
| Item | Value |
|---------------------|--------------------------------------------------------------------------------------|
| Family | Volatility |
| Sub-category | range-average |
| Input type | `Candle` (uses `high`, `low`, `close`) |
| Output type | `f64` |
| Output range | unbounded `≥ 0` |
| Default parameters | `period = 14` (Wilder) |
| Warmup period | `period` (14 for defaults) |
| Interpretation | dollar-denominated volatility scale; rises in chop and expansion |
## Formula
For each candle, the **true range** is
`TR_t = max(H_t - L_t, |H_t - C_{t-1}|, |L_t - C_{t-1}|)` when a previous
close is available, otherwise `TR_t = H_t - L_t` (see `Candle::true_range`
in `crates/wickra-core/src/ohlcv.rs`).
ATR is then Wilder-smoothed:
```
seed_ATR_period = (TR_1 + TR_2 + … + TR_period) / period
ATR_t = ((period - 1) * ATR_{t-1} + TR_t) / period for t > period
```
This is mathematically the same recursion as an EMA with `alpha = 1/period`
(Wilder smoothing), seeded with a simple mean of the first `period` true
ranges (`crates/wickra-core/src/indicators/atr.rs:58-69`).
## Parameters
| Name | Type | Default | Constraint | Source |
|----------|---------|---------|------------|-------------------------------------|
| `period` | `usize` | `14` | `> 0` | `Atr::new` (`atr.rs:26`) |
Python default from `#[pyo3(signature = (period=14))]` in
`bindings/python/src/lib.rs`. `period == 0` returns `Error::PeriodZero`.
## Inputs / Outputs
```rust
impl Indicator for Atr {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
fn warmup_period(&self) -> usize { self.period }
}
```
- **Rust input.** A full `Candle` struct; only `high`, `low`, and `close`
are read (`prev_close` is cached internally between calls).
- **Python streaming.** Accepts either a 6-tuple
`(open, high, low, close, volume, timestamp)` or a dict with keys
`open`, `high`, `low`, `close`, `volume`, and optional `timestamp`.
- **Python batch.** `ATR.batch(high, low, close)` takes three equal-length
`numpy.ndarray` columns and returns a 1-D `np.ndarray` with `NaN` for
every warmup row.
- **Node streaming.** `atr.update(high, low, close)` returns `number | null`.
- **Node batch.** `atr.batch(high, low, close)` returns `Array<number>` of
the same length, `NaN` during warmup.
## Warmup
`warmup_period() == period`. The first `period - 1` candles return `None`
(or `NaN`/`null` in batch); the `period`-th candle returns the seed value
`(TR_1 + … + TR_period) / period`. Each subsequent candle applies the
Wilder recursion.
Verified for `period = 3`: the first non-`None` output is at index `2`
(the 3rd candle).
## Edge cases
- **First candle.** `Candle::true_range(None)` falls back to `high - low`
because there is no previous close yet. The first TR is the bar range.
- **Gaps.** With a previous close at `5.0` and a candle of `H=10, L=9`,
`TR = max(1, 5, 4) = 5` — i.e. `|H - prev_close|` dominates. The
pinned test `gap_up_uses_high_minus_prev_close` covers exactly this.
- **Constant input.** A series of identical candles (no gaps, fixed range)
yields a constant ATR equal to the bar range, even before the seed is
complete — the smoothing has nothing to smooth.
- **Non-negativity.** ATR is always `≥ 0`. The Rust test `never_negative`
pins this property across a sinusoidal price series.
- **NaN / infinity.** `Candle::new` rejects non-finite `open`/`high`/
`low`/`close`/`volume`; constructing the candle returns
`Error::InvalidCandle` before it can ever reach ATR.
- **Reset.** `reset()` clears `prev_close`, the seed buffer, and the
running average; the next call behaves as if the indicator were
freshly constructed.
## Examples
### Rust
```rust
use wickra::{Atr, BatchExt, Candle, Indicator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles = vec![
Candle::new(10.0, 11.0, 9.0, 10.5, 1.0, 0)?,
Candle::new(10.5, 12.0, 10.0, 11.5, 1.0, 0)?,
Candle::new(11.5, 13.0, 11.0, 12.5, 1.0, 0)?,
Candle::new(12.5, 14.0, 12.0, 13.5, 1.0, 0)?,
Candle::new(13.5, 15.0, 13.0, 14.5, 1.0, 0)?,
];
let mut atr = Atr::new(3)?;
println!("{:?}", atr.batch(&candles));
Ok(())
}
```
Output:
```
[None, None, Some(2.0), Some(2.0), Some(2.0)]
```
Every bar has range `2.0` and no gap-driven TR component, so both the
seed `(2 + 2 + 2) / 3 = 2.0` and every subsequent Wilder update stay at
`2.0`.
### Python
```python
import numpy as np
import wickra as ta
atr = ta.ATR(3)
high = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
low = np.array([ 9.0, 10.0, 11.0, 12.0, 13.0])
close = np.array([10.5, 11.5, 12.5, 13.5, 14.5])
print(atr.batch(high, low, close))
```
Output:
```
[nan nan 2. 2. 2.]
```
### Node
```js
const w = require('wickra');
const atr = new w.ATR(3);
console.log(atr.batch(
[11, 12, 13, 14, 15],
[ 9, 10, 11, 12, 13],
[10.5, 11.5, 12.5, 13.5, 14.5],
));
```
Output:
```
[ NaN, NaN, 2, 2, 2 ]
```
Streaming form (`atr.update(high, low, close)`):
```js
const w = require('wickra');
const atr = new w.ATR(3);
console.log(atr.update(11, 9, 10.5));
console.log(atr.update(12, 10, 11.5));
console.log(atr.update(13, 11, 12.5));
console.log(atr.update(14, 12, 13.5));
```
Output:
```
null
null
2
2
```
## Interpretation
- **Stop sizing.** A common pattern is "place a stop `k * ATR` away from
entry," with `k` typically in `[1.5, 3.0]` depending on the timeframe.
ATR's units are price, so the stop distance is directly tradable.
- **Position sizing.** `risk_per_trade / ATR` gives a quantity that
normalises risk across assets of very different price levels.
- **Regime detection.** Persistently rising ATR signals an expansion
regime; persistently low ATR signals consolidation, often preceding
expansion (the volatility-of-volatility argument).
## Common pitfalls
- **Wilder smoothing vs EMA.** Wilder's smoothing factor is `1/period`,
not the EMA's `2/(period+1)`. They look similar but produce different
numbers; a 14-period Wilder ATR is **not** the same as a 14-period
EMA of true range. Wickra uses the Wilder recursion explicitly.
- **Off-by-one seeding.** ATR(14) emits its first value on the 14th
candle, not the 15th — unlike RSI(14) which needs 15 candles for 14
diffs. The difference is that ATR's seed uses `period` true ranges
directly (and `TR_1` is well-defined even without a previous close),
while RSI(14) needs 14 *differences* between consecutive closes.
## References
- J. Welles Wilder Jr., *New Concepts in Technical Trading Systems*,
Trend Research, 1978. Chapter on the Average True Range and the
Wilder smoothing constant.
## See also
- [Bollinger Bands](Indicator-BollingerBands.md) — stddev-based volatility
envelope around an SMA.
- [Keltner Channels](Indicator-Keltner.md) — directly composes EMA + ATR.
- [Donchian Channels](Indicator-Donchian.md) — rolling high/low without
any smoothing.
- [PSAR](Indicator-Psar.md) — uses ATR-like volatility tracking implicitly
through its acceleration factor.
@@ -0,0 +1,258 @@
# Bollinger Bands
> An SMA centerline wrapped in symmetric standard-deviation envelopes; the
> classical reading is that price persistently outside a band signals a
> volatility-driven trend, not a reversal.
## Quick reference
| Item | Value |
|---------------------|--------------------------------------------------------------------------------|
| Family | Volatility |
| Sub-category | envelope |
| Input type | `f64` (typically the close price) |
| Output type | `BollingerOutput { upper: f64, middle: f64, lower: f64, stddev: f64 }` |
| Output range | unbounded; `lower ≤ middle ≤ upper`, `stddev ≥ 0` |
| Default parameters | `period = 20`, `multiplier = 2.0` |
| Warmup period | `period` (20 for defaults) |
| Interpretation | width tracks recent volatility; price tags band on momentum |
## Formula
Each step uses the trailing window of the last `period` inputs:
```
mean = (1/n) * Σ x_i
var = (1/n) * Σ (x_i - mean)^2 (population variance, denominator = n)
stddev = sqrt(var)
upper = mean + multiplier * stddev
middle = mean
lower = mean - multiplier * stddev
```
Wickra computes `var` from the streaming sums `Σ x` and `Σ x²` as
`Σx²/n - (Σx/n)²` and clamps to `0.0` to absorb catastrophic cancellation on
near-constant inputs (`crates/wickra-core/src/indicators/bollinger.rs:82`).
## Parameters
| Name | Type | Default | Constraint | Source |
|--------------|---------|---------|----------------------|----------------------------------------------------------|
| `period` | `usize` | `20` | `> 0` | `BollingerBands::new` (`bollinger.rs:43`) |
| `multiplier` | `f64` | `2.0` | finite and `> 0.0` | `BollingerBands::new` (`bollinger.rs:47`) |
Python defaults come from `#[pyo3(signature = (period=20, multiplier=2.0))]`
in `bindings/python/src/lib.rs`. Invalid inputs raise `ValueError` in Python
and return `Error::PeriodZero` / `Error::NonPositiveMultiplier` in Rust.
## Inputs / Outputs
Rust signature:
```rust
impl Indicator for BollingerBands {
type Input = f64;
type Output = BollingerOutput;
fn update(&mut self, input: f64) -> Option<BollingerOutput>;
fn warmup_period(&self) -> usize { self.period }
}
```
`BollingerOutput` fields: `upper`, `middle`, `lower`, `stddev`.
- **Python streaming** (`update`) returns the 4-tuple `(upper, middle, lower, stddev)`
or `None` during warmup.
- **Python batch** (`batch`) returns a 2-D `numpy.ndarray` of shape `(n, 4)` with
columns `[upper, middle, lower, stddev]`; warmup rows are entirely `NaN`.
- **Node streaming** (`update`) returns a `{ upper, middle, lower, stddev }`
object or `null` during warmup.
- **Node batch** (`batch`) returns a flat `Array<number>` of length `n * 4`
interleaved per row: `[u0, m0, l0, s0, u1, m1, l1, s1, …]`. Warmup rows
are four consecutive `NaN`s.
## Warmup
`warmup_period() == period`. The first `period - 1` inputs return `None`; the
`period`-th input emits the first `BollingerOutput`. Verified for `period = 5`:
the first non-`None` value appears on the 5th input (index 4).
## Edge cases
- **Constant input.** With a flat series the population stddev collapses to
exactly `0.0`, so `upper == middle == lower == mean`. The library guards
against tiny negative floating-point values from catastrophic cancellation
by clamping the variance with `.max(0.0)`.
- **Flat range / squeeze.** Real markets never give exactly `0.0`, but very
low-volatility windows produce visibly narrow bands; the upper and lower
bands collapse onto the middle band (the "Bollinger squeeze").
- **NaN / infinity input.** The implementation skips non-finite inputs:
`if !input.is_finite() { return self.current(); }`. The window is not
advanced and the previous `BollingerOutput` (or `None`) is returned.
- **Multiplier validation.** `multiplier <= 0` or non-finite returns
`Error::NonPositiveMultiplier`. `period == 0` returns `Error::PeriodZero`.
- **Reset.** `reset()` clears the window and both running sums, returning the
indicator to a freshly-constructed state.
## Examples
### Rust
```rust
use wickra::{BatchExt, BollingerBands, Indicator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut bb = BollingerBands::new(5, 2.0)?;
let out = bb.batch(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]);
for (i, v) in out.into_iter().enumerate() {
println!("i={i} -> {:?}", v);
}
Ok(())
}
```
Output:
```
i=0 -> None
i=1 -> None
i=2 -> None
i=3 -> None
i=4 -> Some(BollingerOutput { upper: 5.759591794226543, middle: 3.8, lower: 1.8404082057734565, stddev: 0.9797958971132716 })
i=5 -> Some(BollingerOutput { upper: 5.379795897113269, middle: 4.4, lower: 3.420204102886732, stddev: 0.48989794855663404 })
i=6 -> Some(BollingerOutput { upper: 7.190890230020663, middle: 5.0, lower: 2.809109769979336, stddev: 1.095445115010332 })
i=7 -> Some(BollingerOutput { upper: 9.577708763999665, middle: 6.0, lower: 2.422291236000335, stddev: 1.7888543819998326 })
```
The first emission at `i=4` uses the window `[2, 4, 4, 4, 5]` with mean
`3.8` and population stddev `sqrt(0.96) ≈ 0.9797959`.
### Python
```python
import numpy as np
import wickra as ta
bb = ta.BollingerBands(5, 2.0)
prices = np.array([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], dtype=float)
out = bb.batch(prices)
print("shape:", out.shape)
print("row 4:", out[4])
print("row 7:", out[7])
```
Output:
```
shape: (8, 4)
row 4: [5.75959179 3.8 1.84040821 0.9797959 ]
row 7: [9.57770876 6. 2.42229124 1.78885438]
```
Streaming variant returns a 4-tuple `(upper, middle, lower, stddev)` per
tick or `None` during warmup:
```python
import wickra as ta
bb = ta.BollingerBands(5, 2.0)
for p in [2.0, 4.0, 4.0, 4.0, 5.0, 9.0]:
print(p, "->", bb.update(p))
```
Output:
```
2.0 -> None
4.0 -> None
4.0 -> None
4.0 -> None
5.0 -> (5.759591794226543, 3.8, 1.8404082057734565, 0.9797958971132716)
9.0 -> (9.078143885933063, 5.2, 1.321856114066938, 1.939071942966531)
```
### Node
```js
const w = require('wickra');
const bb = new w.BollingerBands(5, 2.0);
const flat = bb.batch([2, 4, 4, 4, 5, 5, 7, 9]);
console.log('length:', flat.length);
console.log('row 4 [upper, middle, lower, stddev]:', flat.slice(16, 20));
console.log('row 7 [upper, middle, lower, stddev]:', flat.slice(28, 32));
```
Output:
```
length: 32
row 4 [upper, middle, lower, stddev]: [ 5.759591794226543, 3.8, 1.8404082057734565, 0.9797958971132716 ]
row 7 [upper, middle, lower, stddev]: [ 9.577708763999665, 6, 2.422291236000335, 1.7888543819998326 ]
```
Streaming returns the named object `{ upper, middle, lower, stddev }`:
```js
const w = require('wickra');
const bb = new w.BollingerBands(5, 2.0);
[2, 4, 4, 4, 5].forEach(p => console.log(p, '->', bb.update(p)));
```
Output:
```
2 -> null
4 -> null
4 -> null
4 -> null
5 -> {
upper: 5.759591794226543,
middle: 3.8,
lower: 1.8404082057734565,
stddev: 0.9797958971132716
}
```
## Interpretation
- **Bandwidth as volatility.** `(upper - lower) / middle` is the Bollinger
bandwidth; a multi-month low in bandwidth is the classic "squeeze" that
often precedes an expansion move.
- **Tags vs breakouts.** A single touch of the upper band is not a sell
signal in Bollinger's own framework; persistent closes outside the band
("walking the band") signal trend continuation, not exhaustion.
- **%b position.** `(price - lower) / (upper - lower)` normalises position
inside the channel and is useful as a feature for cross-asset comparison.
## Common pitfalls
- **Stddev convention.** Wickra uses **population** standard deviation
(denominator `n`, not `n - 1`). This matches Bollinger's original
formulation and every reference implementation (TA-Lib, pandas-ta);
switching to the sample variant would mis-align bands by a factor of
`sqrt(n / (n - 1))` and break parity with other tools.
- **Partial rows.** In the Python 2-D batch result, do not slice an
individual column out and use it for analysis without checking for
`NaN` — every warmup row is `NaN` across all four columns. Filter with
`mask = ~np.isnan(out[:, 0])` before reading any single column.
- **Flat batch length in Node.** The Node `batch` returns `n * 4` numbers
interleaved per row, not four parallel arrays. Reshape with
`Array.from({ length: n }, (_, i) => flat.slice(i * 4, i * 4 + 4))`
if you want per-row records.
## References
- John Bollinger, *Bollinger on Bollinger Bands*, McGraw-Hill, 2001 (the
original publication of the indicator dates to the early 1980s).
- Wilder's *New Concepts in Technical Trading Systems* (1978) for the
surrounding family of volatility envelopes.
## See also
- [Keltner Channels](Indicator-Keltner.md) — same envelope shape but band
width is driven by ATR instead of stddev.
- [Donchian Channels](Indicator-Donchian.md) — rolling high/low envelope
with no smoothing.
- [ATR](Indicator-Atr.md) — the volatility scale most commonly used to
size Bollinger-style stops.
@@ -0,0 +1,214 @@
# Donchian Channels
> The unsmoothed price-extreme envelope: highest high and lowest low over a
> rolling window, with the mid-band defined as their average. Breakouts of
> the Donchian channel are the foundation of the Turtle trading rules.
## Quick reference
| Item | Value |
|---------------------|--------------------------------------------------------------------|
| Family | Volatility |
| Sub-category | envelope (rolling extrema) |
| Input type | `Candle` (uses `high` and `low`) |
| Output type | `DonchianOutput { upper: f64, middle: f64, lower: f64 }` |
| Output range | unbounded; `lower ≤ middle ≤ upper` |
| Default parameters | `period = 20` |
| Warmup period | `period` (20 for defaults) |
| Interpretation | breakout boundary; channel touches are tradable events |
## Formula
For a lookback of `period` candles:
```
upper_t = max( high_t, high_{t-1}, …, high_{t-period+1} )
lower_t = min( low_t, low_{t-1}, …, low_{t-period+1} )
middle_t = (upper_t + lower_t) / 2
```
`crates/wickra-core/src/indicators/donchian.rs:58-72` computes both
extrema by folding over the in-window candles each tick; this is O(n)
per update in the period size and O(1) in the data length.
## Parameters
| Name | Type | Default | Constraint | Source |
|----------|---------|---------|------------|-----------------------------------------|
| `period` | `usize` | `20` | `> 0` | `Donchian::new` (`donchian.rs:30`) |
Python default from `#[pyo3(signature = (period=20))]` in
`bindings/python/src/lib.rs`. `period == 0` returns `Error::PeriodZero`.
## Inputs / Outputs
```rust
impl Indicator for Donchian {
type Input = Candle;
type Output = DonchianOutput;
fn update(&mut self, candle: Candle) -> Option<DonchianOutput>;
}
pub struct DonchianOutput { pub upper: f64, pub middle: f64, pub lower: f64 }
```
- **Python streaming.** Returns `(upper, middle, lower)` tuple or `None`.
- **Python batch.** `Donchian.batch(high, low)` returns a 2-D
`np.ndarray` of shape `(n, 3)` with columns `[upper, middle, lower]`;
warmup rows are `NaN` across all three columns. (`close` is not
required.)
- **Node streaming.** Not exposed — the Node binding ships only the
`batch` form for `Donchian`.
- **Node batch.** `donchian.batch(high, low)` returns a flat
`Array<number>` of length `n * 3` interleaved per row:
`[u0, m0, l0, u1, m1, l1, …]`.
## Warmup
`warmup_period() == period`. The first `period - 1` candles return
`None`; the `period`-th candle emits the first envelope. Verified for
`period = 3`: the first non-`None` output is at index `2` (the 3rd
candle).
## Edge cases
- **Flat market (HH == LL).** When every candle in the window has
identical highs and identical lows, `upper == lower` (and therefore
`middle == upper == lower`). The pinned test
`flat_market_yields_equal_bands` covers this.
- **Single extreme candle.** A lone wick at the edge of the window sets
the boundary until it scrolls out. Donchian therefore reacts in
step-functions, not smoothly — a new all-time high inside the window
immediately moves the upper band; a single bar later, that high
remains the boundary unless an even higher print occurs.
- **NaN / infinity.** `Candle::new` rejects non-finite OHLC values
before they can reach Donchian.
- **Reset.** `reset()` clears the candle window; the configured
`period` is preserved.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Donchian, Indicator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles = vec![
Candle::new(10.0, 11.0, 9.0, 10.5, 1.0, 0)?,
Candle::new(10.5, 12.0, 10.0, 11.5, 1.0, 0)?,
Candle::new(11.5, 13.0, 11.0, 12.5, 1.0, 0)?,
Candle::new(12.5, 14.0, 12.0, 13.5, 1.0, 0)?,
Candle::new(13.5, 15.0, 13.0, 14.5, 1.0, 0)?,
];
let mut d = Donchian::new(3)?;
for (i, v) in d.batch(&candles).into_iter().enumerate() {
println!("i={i} -> {:?}", v);
}
Ok(())
}
```
Output:
```
i=0 -> None
i=1 -> None
i=2 -> Some(DonchianOutput { upper: 13.0, middle: 11.0, lower: 9.0 })
i=3 -> Some(DonchianOutput { upper: 14.0, middle: 12.0, lower: 10.0 })
i=4 -> Some(DonchianOutput { upper: 15.0, middle: 13.0, lower: 11.0 })
```
At `i = 2` the window contains highs `[11, 12, 13]` and lows `[9, 10, 11]`,
so `upper = 13`, `lower = 9`, `middle = 11`.
### Python
```python
import numpy as np
import wickra as ta
d = ta.Donchian(3)
h = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
l = np.array([ 9.0, 10.0, 11.0, 12.0, 13.0])
print(d.batch(h, l))
```
Output:
```
[[nan nan nan]
[nan nan nan]
[13. 11. 9.]
[14. 12. 10.]
[15. 13. 11.]]
```
### Node
```js
const w = require('wickra');
const d = new w.Donchian(3);
const flat = d.batch(
[11, 12, 13, 14, 15],
[ 9, 10, 11, 12, 13],
);
console.log('length:', flat.length);
console.log('row 2 [upper, middle, lower]:', flat.slice(6, 9));
console.log('row 4 [upper, middle, lower]:', flat.slice(12, 15));
```
Output:
```
length: 15
row 2 [upper, middle, lower]: [ 13, 11, 9 ]
row 4 [upper, middle, lower]: [ 15, 13, 11 ]
```
## Interpretation
- **Breakouts.** The original Turtle Trading rules (Dennis / Eckhardt,
early 1980s) buy on a 20-day Donchian upper-band breach and sell on
a 10-day lower-band breach. The modern descendant is the "channel
breakout" family of trend-following systems.
- **Mean reversion.** A small minority of systems take the bands as
fade levels; this works on range-bound assets and fails dramatically
in trends — the inverse of breakout systems.
- **Volatility proxy.** Channel width `upper - lower` is a simple
volatility proxy that requires no smoothing and no parameter tuning
beyond the lookback length.
## Common pitfalls
- **Stale extreme.** A single shock high from `period` candles ago
keeps the upper band elevated even when current prices have fallen
back to normal. Watch for the "channel drop" event when that high
scrolls out of the window — the upper band will step down sharply
in a single bar.
- **No close required.** Donchian only uses high/low. Feeding it a
close-only series (with high = low = close) collapses it into an
envelope of close extremes, which is a much noisier signal than
the canonical high/low form. The Python `batch` accepts only
`(high, low)` for exactly this reason.
- **Flat range collapse.** On a truly flat instrument the channel
collapses to a line (`upper == middle == lower`); downstream code
that divides by `upper - lower` (e.g. computing channel position)
must handle this division-by-zero case explicitly.
## References
- Richard Donchian published the 4-week channel rule in the early
1960s as part of his broader trend-following work.
- Curtis Faith, *Way of the Turtle*, McGraw-Hill, 2007, documents
the 20/10-day Donchian variant that defined the Turtle program.
## See also
- [Bollinger Bands](Indicator-BollingerBands.md) — envelope shaped by
stddev rather than rolling extrema.
- [Keltner Channels](Indicator-Keltner.md) — envelope shaped by ATR
around an EMA centerline.
- [PSAR](Indicator-Psar.md) — alternative trailing-stop construction
for breakout systems.
@@ -0,0 +1,220 @@
# Keltner Channels
> A pure composition of [EMA](../trend/Indicator-Ema.md) on typical price plus
> ATR-scaled envelopes. The middle line is the trend filter, the bands are
> the volatility cone.
## Quick reference
| Item | Value |
|---------------------|------------------------------------------------------------------------------------|
| Family | Volatility |
| Sub-category | envelope (composed: EMA + ATR) |
| Input type | `Candle` (uses `high`, `low`, `close`) |
| Output type | `KeltnerOutput { upper: f64, middle: f64, lower: f64 }` |
| Output range | unbounded; `lower ≤ middle ≤ upper` |
| Default parameters | `ema_period = 20`, `atr_period = 10`, `multiplier = 2.0` |
| Warmup period | `max(ema_period, atr_period)` (`20` for defaults) — see Warmup notes |
| Interpretation | trend-following envelope; tags signal momentum, not exhaustion |
## Formula
```
middle_t = EMA_{ema_period}( typical_price_t ) // tp = (H+L+C)/3
upper_t = middle_t + multiplier * ATR_{atr_period}_t
lower_t = middle_t - multiplier * ATR_{atr_period}_t
```
The middle line is an EMA of **typical price**, not of close
(`crates/wickra-core/src/indicators/keltner.rs:62`,
`candle.typical_price()`).
## Parameters
| Name | Type | Default | Constraint | Source |
|--------------|---------|---------|-------------------------|----------------------------------------------|
| `ema_period` | `usize` | `20` | `> 0` | `Keltner::new` (`keltner.rs:33`) |
| `atr_period` | `usize` | `10` | `> 0` | `Keltner::new` (`keltner.rs:33`) |
| `multiplier` | `f64` | `2.0` | finite and `> 0.0` | `Keltner::new` (`keltner.rs:34-36`) |
Python defaults from
`#[pyo3(signature = (ema_period=20, atr_period=10, multiplier=2.0))]` in
`bindings/python/src/lib.rs`. `Keltner::classic()` returns the same
configuration.
## Inputs / Outputs
```rust
impl Indicator for Keltner {
type Input = Candle;
type Output = KeltnerOutput;
fn update(&mut self, candle: Candle) -> Option<KeltnerOutput>;
}
pub struct KeltnerOutput { pub upper: f64, pub middle: f64, pub lower: f64 }
```
- **Python streaming.** Returns `(upper, middle, lower)` tuple or `None`.
- **Python batch.** `Keltner.batch(high, low, close)` returns a 2-D
`np.ndarray` of shape `(n, 3)` with columns `[upper, middle, lower]`;
warmup rows are `NaN` across all three columns.
- **Node streaming.** Returns a `{ upper, middle, lower }` object or
`null`.
- **Node batch.** `keltner.batch(high, low, close)` returns a flat
`Array<number>` of length `n * 3` interleaved per row:
`[u0, m0, l0, u1, m1, l1, …]`.
## Warmup
`warmup_period()` reports `max(ema_period, atr_period)` — for the
default `(20, 10, 2.0)` that is `20`.
**Important caveat verified empirically.** Because `Keltner::update`
calls `self.ema.update(...)?` *before* `self.atr.update(...)?`, the ATR
sub-indicator only receives an input on candles where the EMA already
has a value. The actual first emission therefore occurs after roughly
`ema_period + atr_period - 1` candles, not `max(ema_period, atr_period)`.
With the classic `(20, 10, 2.0)` configuration the first non-`None`
output is the 29th candle (index `28`), not the 20th. Code reference:
`keltner.rs:61-69`. Plan your data prefix accordingly.
## Edge cases
- **Flat market.** A constant-OHLC series produces `upper == middle == lower`
because ATR collapses to `0`. The pinned test
`flat_market_collapses_bands` covers this.
- **Trending market.** When ATR rises, both bands widen symmetrically
around the EMA centerline.
- **Reset.** `reset()` resets both the underlying EMA and ATR; the
configured periods/multiplier are preserved.
- **NaN / infinity.** `Candle::new` rejects non-finite OHLC values up
front; the indicator never receives them.
- **Invalid params.** `ema_period == 0`, `atr_period == 0`, or non-positive
`multiplier` returns an error from `Keltner::new`.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, Keltner};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles = vec![
Candle::new(10.0, 11.0, 9.0, 10.5, 1.0, 0)?,
Candle::new(10.5, 12.0, 10.0, 11.5, 1.0, 0)?,
Candle::new(11.5, 13.0, 11.0, 12.5, 1.0, 0)?,
Candle::new(12.5, 14.0, 12.0, 13.5, 1.0, 0)?,
Candle::new(13.5, 15.0, 13.0, 14.5, 1.0, 0)?,
];
let mut k = Keltner::new(3, 3, 2.0)?;
for (i, v) in k.batch(&candles).into_iter().enumerate() {
println!("i={i} -> {:?}", v);
}
Ok(())
}
```
Output:
```
i=0 -> None
i=1 -> None
i=2 -> None
i=3 -> None
i=4 -> Some(KeltnerOutput { upper: 17.166666666666664, middle: 13.166666666666666, lower: 9.166666666666666 })
```
Notice the first emission is at `i = 4` (the 5th candle), not `i = 2`,
even though `max(ema=3, atr=3) = 3`. This is the EMA-gates-ATR effect
documented under **Warmup**.
### Python
```python
import numpy as np
import wickra as ta
k = ta.Keltner(3, 3, 2.0)
h = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
l = np.array([ 9.0, 10.0, 11.0, 12.0, 13.0])
c = np.array([10.5, 11.5, 12.5, 13.5, 14.5])
print(k.batch(h, l, c))
```
Output:
```
[[ nan nan nan]
[ nan nan nan]
[ nan nan nan]
[ nan nan nan]
[17.16666667 13.16666667 9.16666667]]
```
### Node
```js
const w = require('wickra');
const k = new w.Keltner(3, 3, 2.0);
const flat = k.batch(
[11, 12, 13, 14, 15],
[ 9, 10, 11, 12, 13],
[10.5, 11.5, 12.5, 13.5, 14.5],
);
console.log('length:', flat.length);
console.log('row 4 [upper, middle, lower]:', flat.slice(12, 15));
```
Output:
```
length: 15
row 4 [upper, middle, lower]: [ 17.166666666666664, 13.166666666666666, 9.166666666666666 ]
```
## Interpretation
- **Trend filter.** Persistent closes above the upper band signal
trend continuation, much like Bollinger's "walking the band" pattern;
Keltner is generally tighter than Bollinger on noisy series because
ATR responds more smoothly than a rolling stddev.
- **Squeeze cross-over.** A common "squeeze" setup compares Bollinger
bandwidth to Keltner channel width: when Bollinger fits *inside*
Keltner, a volatility expansion is statistically more likely.
- **Pullback entries.** In a defined uptrend, pullbacks to the middle
EMA line are a classic continuation entry; the lower band acts as
the disaster stop.
## Common pitfalls
- **Reported warmup understates the true warmup.** `warmup_period()`
reports `max(ema_period, atr_period)`, but because the EMA is
evaluated first and short-circuits the ATR update via `?`, the
indicator only emits after roughly `ema_period + atr_period - 1`
candles. For the classic `(20, 10, 2.0)` you need 29 candles, not
20, before the first valid `KeltnerOutput`. Inspecting
`is_ready()` is the safest gate.
- **Typical price ≠ close.** The middle EMA runs on
`(H + L + C) / 3`, not on close. A pre-computed "EMA of close"
panel will not equal the Keltner middle line and trying to align
them at floating-point precision will fail.
## References
- Chester W. Keltner, *How to Make Money in Commodities*, 1960. The
original construction used a 10-day SMA of typical price with an
envelope sized by the 10-day average range. The modern variant
(EMA centerline + ATR envelope) is the form Wickra implements.
- Linda Bradford Raschke popularised the EMA + ATR rephrasing in the
1990s; this is the version most TA libraries ship today.
## See also
- [EMA](../trend/Indicator-Ema.md) — the centerline component.
- [ATR](Indicator-Atr.md) — the envelope width component.
- [Bollinger Bands](Indicator-BollingerBands.md) — envelope using stddev
rather than ATR; useful side-by-side comparison.
- [Donchian Channels](Indicator-Donchian.md) — envelope using rolling
extrema with no smoothing.
@@ -0,0 +1,247 @@
# PSAR (Parabolic SAR)
> Wilder's parabolic Stop-And-Reverse: a state-machine trailing stop that
> accelerates toward price as a trend extends and flips sides on a
> penetration of the SAR line.
## Quick reference
| Item | Value |
|---------------------|------------------------------------------------------------------------------------|
| Family | Volatility |
| Sub-category | trailing-stop (state machine) |
| Input type | `Candle` (uses `high`, `low`) |
| Output type | `f64` |
| Output range | unbounded; bracketed by the prior two highs/lows |
| Default parameters | `af_start = 0.02`, `af_step = 0.02`, `af_max = 0.20` (Wilder) |
| Warmup period | `2` (state machine seeds on the 2nd candle) |
| Interpretation | trailing stop that "flips" sides on penetration; never tied to a fixed bar count |
## Formula
PSAR is a two-state machine — `Up` (long bias) and `Down` (short bias).
Each bar updates three pieces of state:
```
EP_t = extreme price reached so far in the current trend (max high in Up,
min low in Down)
AF_t = acceleration factor, bumped by af_step each time EP makes a new
extreme, capped at af_max
SAR_t = stop-and-reverse level
```
The transition is:
```
SAR_t = SAR_{t-1} + AF_{t-1} * (EP_{t-1} - SAR_{t-1})
# Wilder rule: SAR cannot penetrate today's or yesterday's range
if Up: SAR_t = min(SAR_t, low_{t-1}, low_t)
if Down: SAR_t = max(SAR_t, high_{t-1}, high_t)
# Reversal test
if Up and low_t <= SAR_t: flip to Down, SAR_t = EP_{t-1}, reset AF
if Down and high_t >= SAR_t: flip to Up, SAR_t = EP_{t-1}, reset AF
```
The exact step-by-step is `crates/wickra-core/src/indicators/psar.rs:75-141`.
## Parameters
| Name | Type | Default | Constraint | Source |
|------------|-------|---------|-------------------------------------------|---------------------------------------|
| `af_start` | `f64` | `0.02` | finite, `> 0`, `≤ af_max` | `Psar::new` (`psar.rs:39-50`) |
| `af_step` | `f64` | `0.02` | finite, `> 0` | `Psar::new` (`psar.rs:39-50`) |
| `af_max` | `f64` | `0.20` | finite, `> 0` | `Psar::new` (`psar.rs:39-50`) |
Python defaults from
`#[pyo3(signature = (af_start=0.02, af_step=0.02, af_max=0.20))]` in
`bindings/python/src/lib.rs`. `Psar::classic()` returns the same triple.
Validation errors:
- non-finite or non-positive AF parameter → `Error::NonPositiveMultiplier`
- `af_start > af_max``Error::InvalidPeriod { message: "af_start must be <= af_max" }`
## Inputs / Outputs
```rust
impl Indicator for Psar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
fn warmup_period(&self) -> usize { 2 }
}
```
- **Python streaming.** Returns `float | None`.
- **Python batch.** `PSAR.batch(high, low, close)` returns a 1-D
`np.ndarray`; the first row is `NaN` (warmup) and every subsequent
row holds the SAR level for that bar.
- **Node streaming.** Not exposed in the Node binding.
- **Node batch.** `psar.batch(high, low, close)` returns
`Array<number>` with `NaN` for the first row.
## Warmup
`warmup_period() == 2`. The very first candle seeds internal state
(`prev_high`, `prev_low`, `sar = low`, `ep = high`, `trend = Up`,
`af = af_start`) and returns `None`. The second candle produces the
first SAR value.
The seed trend is **always** `Up` (`psar.rs:83`); the indicator will
reverse to `Down` on the first qualifying penetration. There is no
look-ahead at the second candle's close — the seed is purely structural.
## Edge cases
- **First bar.** Always returns `None`; downstream code must tolerate
the first row being absent without crashing.
- **Pure uptrend.** With monotonically rising highs and lows, the SAR
remains below the lows and accelerates toward price as the EP makes
successive new highs. The pinned test `pure_uptrend_sar_below_lows`
asserts `SAR ≤ low` on every emitted bar of a 40-bar ramp.
- **Pure downtrend.** Symmetrically, with monotonically falling highs,
the SAR sits above the highs after the trend establishes.
`pure_downtrend_sar_above_highs` covers this.
- **Reversal mechanics.** When the trend flips, `SAR` is set to the
previous EP (not the calculated parabola value), AF is reset to
`af_start`, and the new EP is the current bar's high (Down→Up) or
low (Up→Down).
- **Choppy regime.** Frequent reversals cause many AF resets; SAR
becomes a poor stop in mean-reverting regimes and whipsaws.
- **NaN / infinity.** `Candle::new` rejects non-finite OHLC values.
`Psar::new` rejects non-finite AF parameters.
- **Reset.** `reset()` clears the initialised flag and resets `af` to
`af_start`, `sar` to `0.0`, `ep` to `0.0`; the next `update` re-seeds.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, Psar};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
Candle::new(base, base + 0.5, base - 0.5, base + 0.25, 1.0, 0).unwrap()
})
.collect();
let mut p = Psar::classic(); // (0.02, 0.02, 0.20)
for (i, v) in p.batch(&candles).into_iter().enumerate() {
println!("i={i} -> {:?}", v);
}
Ok(())
}
```
Output:
```
i=0 -> None
i=1 -> Some(99.5)
i=2 -> Some(99.58)
i=3 -> Some(99.7552)
i=4 -> Some(100.054784)
i=5 -> Some(100.4993056)
i=6 -> Some(101.099388928)
i=7 -> Some(101.85547447808)
```
The SAR starts at `99.5` (the first candle's low) and accelerates
upward toward price as the EP makes new highs on every bar.
### Python
```python
import numpy as np
import wickra as ta
p = ta.PSAR() # defaults (0.02, 0.02, 0.20)
h = np.array([100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5])
l = np.array([ 99.5, 100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5])
cl = np.array([100.25, 101.25, 102.25, 103.25, 104.25, 105.25, 106.25, 107.25])
print(p.batch(h, l, cl))
```
Output:
```
[ nan 99.5 99.58 99.7552 100.054784
100.4993056 101.09938893 101.85547448]
```
### Node
```js
const w = require('wickra');
const p = new w.PSAR(0.02, 0.02, 0.20);
console.log(p.batch(
[100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5],
[ 99.5, 100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5],
[100.25, 101.25, 102.25, 103.25, 104.25, 105.25, 106.25, 107.25],
));
```
Output:
```
[
NaN,
99.5,
99.58,
99.7552,
100.054784,
100.4993056,
101.099388928,
101.85547447808
]
```
## Interpretation
- **Stop & reverse.** PSAR is a *trailing stop*, not a signal generator
in isolation: a long is exited (and a short is initiated) the bar
that price penetrates the SAR line.
- **Acceleration.** The further a trend extends without making new
extremes, the slower the SAR rises (or falls). When EP makes a new
extreme, AF bumps by `af_step` and the SAR closes the distance to
price more aggressively.
- **Whipsaw risk.** In sideways markets PSAR flips repeatedly; pair it
with a trend filter (ADX, slope of EMA) to skip trades when the
underlying isn't actually trending.
## Common pitfalls
- **The first bar always returns `None`.** Code that pre-allocates a
vector and does `out[i] = psar.update(c).unwrap()` will panic on
the very first input. Use `if let Some(...)` or skip the first
row explicitly.
- **Initial trend is hard-coded to `Up`.** The seed bar always sets
`trend = Up`, regardless of whether the data is in a downtrend.
Expect a near-immediate reversal to `Down` if you feed PSAR a
decisively bearish series — the first emitted SAR may look
"wrong" because it is the prior EP from the artificial `Up`
seed, not from a real bullish run.
- **Acceleration cap matters.** `af_max = 0.20` is Wilder's choice;
raising it produces an extremely tight stop near tops/bottoms but
exits good trends prematurely. Lowering it produces a forgiving
stop that gives back more open profit. Always re-validate strategy
PnL when you change `af_max`.
## References
- J. Welles Wilder Jr., *New Concepts in Technical Trading Systems*,
Trend Research, 1978. Chapter on the Parabolic SAR introduces the
state-machine recursion and the default `(0.02, 0.02, 0.20)`
parameters.
## See also
- [ATR](Indicator-Atr.md) — sister indicator from the same Wilder text.
- [Donchian Channels](Indicator-Donchian.md) — alternative breakout-style
trailing stop based on rolling extrema.
- [Keltner Channels](Indicator-Keltner.md) — envelope you can use as a
smoother stop boundary than PSAR in choppy regimes.
@@ -0,0 +1,190 @@
# OBV (On-Balance Volume)
> A cumulative signed-volume series: each candle adds its volume on an up
> close, subtracts on a down close, and leaves the running total unchanged
> on a flat close. The shape of the OBV curve, not its absolute level, is
> what carries information.
## Quick reference
| Item | Value |
|---------------------|--------------------------------------------------------------|
| Family | Volume |
| Sub-category | cumulative |
| Input type | `Candle` (uses `close` and `volume`) |
| Output type | `f64` |
| Output range | unbounded (signed, integer-of-volume in spirit) |
| Default parameters | none |
| Warmup period | `1` |
| Interpretation | divergence vs price signals accumulation / distribution |
## Formula
For each candle `t > 0` (after the seed):
```
if close_t > close_{t-1}: OBV_t = OBV_{t-1} + volume_t
if close_t < close_{t-1}: OBV_t = OBV_{t-1} - volume_t
if close_t == close_{t-1}: OBV_t = OBV_{t-1}
```
The first candle initialises the running total to `0.0` and emits
that value (`crates/wickra-core/src/indicators/obv.rs:42-55`).
## Parameters
`Obv::new()` takes no parameters. Python: `wickra.OBV()`. Node:
`new w.OBV()`.
## Inputs / Outputs
```rust
impl Indicator for Obv {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
fn warmup_period(&self) -> usize { 1 }
}
```
- **Python streaming.** Accepts a 6-tuple or dict candle; returns
`float | None`.
- **Python batch.** `OBV.batch(close, volume)` takes two equal-length
1-D `numpy.ndarray` columns and returns a 1-D `np.ndarray`. The
first value is `0.0`, never `NaN`.
- **Node streaming.** Not exposed; the Node binding ships only
`batch` for `OBV`.
- **Node batch.** `obv.batch(close, volume)` returns `Array<number>`
of the same length.
## Warmup
`warmup_period() == 1`. The very first candle emits `0.0` by
convention (the "baseline" — there is no prior close to compare
against, so the indicator starts the running total at zero). Every
subsequent candle emits the updated cumulative total.
## Edge cases
- **First bar.** Always emits `0.0` (pinned test
`first_candle_baseline_zero`). This is the canonical OBV convention
used by Granville's original formulation.
- **Equal closes.** A candle with `close_t == close_{t-1}` does not
change the running total — the volume is discarded. (`obv.rs:46-50`).
- **Down close.** Subtracts the bar's volume, so OBV can go strongly
negative on a sustained downtrend; that is expected and meaningful.
- **Zero volume.** A zero-volume bar adds or subtracts `0`, so OBV
is unchanged regardless of close direction.
- **NaN / infinity.** `Candle::new` rejects non-finite OHLCV values
before they reach OBV.
- **Reset.** `reset()` zeroes the running total and clears the
`has_emitted` / `prev_close` state.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, Obv};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles = vec![
Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, 0)?, // baseline -> 0
Candle::new(10.0, 11.0, 10.0, 11.0, 20.0, 0)?, // up -> +20
Candle::new(11.0, 11.0, 10.5, 10.5, 30.0, 0)?, // down -> -30
Candle::new(10.5, 10.5, 10.5, 10.5, 40.0, 0)?, // flat -> 0
Candle::new(10.5, 12.0, 10.5, 12.0, 10.0, 0)?, // up -> +10
];
let mut obv = Obv::new();
println!("{:?}", obv.batch(&candles));
Ok(())
}
```
Output:
```
[Some(0.0), Some(20.0), Some(-10.0), Some(-10.0), Some(0.0)]
```
Hand check: baseline `0`, then `0 + 20 = 20`, then `20 - 30 = -10`,
then `-10` (flat close discards the 40), then `-10 + 10 = 0`.
### Python
```python
import numpy as np
import wickra as ta
obv = ta.OBV()
c = np.array([10.0, 11.0, 10.5, 10.5, 12.0])
v = np.array([100.0, 20.0, 30.0, 40.0, 10.0])
print(obv.batch(c, v))
```
Output:
```
[ 0. 20. -10. -10. 0.]
```
### Node
```js
const w = require('wickra');
const obv = new w.OBV();
console.log(obv.batch(
[10, 11, 10.5, 10.5, 12],
[100, 20, 30, 40, 10],
));
```
Output:
```
[ 0, 20, -10, -10, 0 ]
```
## Interpretation
- **Divergence is the signal.** OBV's absolute level depends entirely
on where the series started and is therefore meaningless on its
own. The interpretable signal is the *shape* of OBV relative to
price: a new price high without a new OBV high (bearish divergence)
suggests the rally is not being confirmed by accumulating buy
volume, and vice versa.
- **Trend confirmation.** A rising OBV that tracks a rising price is
confirmation of the trend; a flattening OBV under a still-rising
price is the canonical warning of distribution.
- **Smoothing.** Many traders apply an SMA or EMA to OBV (e.g. 20-period
SMA) and treat crossings of that smoothed line as buy/sell triggers.
## Common pitfalls
- **Absolute value is arbitrary.** Comparing OBV values across
different start times or different instruments is meaningless —
only slopes, divergences, and crossings of derived smoothers carry
signal.
- **Flat closes discard volume.** A candle that closes exactly at the
previous close contributes nothing to OBV no matter how heavy its
volume. Some practitioners prefer A/D-style alternatives (e.g.
Chaikin Money Flow) that distribute the volume according to where
in the bar's range the close landed, precisely to avoid this
discontinuity.
## References
- Joseph Granville, *Granville's New Strategy of Daily Stock Market
Timing for Maximum Profit*, Prentice-Hall, 1976. The OBV
construction was first popularised in Granville's earlier 1963
work and refined in his subsequent books.
## See also
- [VWAP](Indicator-Vwap.md) — volume-weighted price benchmark; OBV and
VWAP are the two canonical volume-aware indicators in the panel.
- [MFI](../momentum/Indicator-Mfi.md) — money-flow index, an oscillator blending
typical price with volume.
- [SMA](../trend/Indicator-Sma.md) / [EMA](../trend/Indicator-Ema.md) — the smoothers
most commonly layered on top of OBV to define trade triggers.
@@ -0,0 +1,290 @@
# VWAP (Volume-Weighted Average Price)
> The volume-weighted mean of typical price; the institutional benchmark for
> "fair" intraday execution. Wickra ships both the unbounded cumulative
> session VWAP and a finite-window `RollingVwap`.
## Quick reference
| Item | Value |
|---------------------|----------------------------------------------------------------|
| Family | Volume |
| Sub-category | cumulative (`Vwap`) / rolling (`RollingVwap`) |
| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) |
| Output type | `f64` |
| Output range | unbounded (price-units) |
| Default parameters | none for `Vwap`; `period` required for `RollingVwap` |
| Warmup period | `1` for `Vwap`, `period` for `RollingVwap` |
| Interpretation | intraday fair-price benchmark for execution |
## Formula
Both variants use the typical price `tp_t = (H_t + L_t + C_t) / 3`
(see `Candle::typical_price` in `crates/wickra-core/src/ohlcv.rs:104-108`).
Cumulative VWAP:
```
VWAP_t = ( Σ_{i=1..t} tp_i * v_i ) / ( Σ_{i=1..t} v_i )
```
Rolling VWAP over the last `period` candles:
```
RollingVWAP_t = ( Σ_{i=t-period+1..t} tp_i * v_i ) / ( Σ_{i=t-period+1..t} v_i )
```
Both forms gate their output: when the relevant volume sum is `0.0`, no
value is emitted (`vwap.rs:50, 121`).
---
## `Vwap` (cumulative)
The session VWAP. State grows forever; call `reset()` at session
boundaries (e.g. the start of the trading day) to restart accumulation.
### Parameters
`Vwap::new()` takes no parameters. Python: `wickra.VWAP()`. Node:
`new w.VWAP()`.
### Inputs / Outputs
```rust
impl Indicator for Vwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
fn warmup_period(&self) -> usize { 1 }
}
```
- **Rust input.** A full `Candle`; the indicator multiplies
`typical_price() * volume` and accumulates.
- **Python batch.** `VWAP.batch(high, low, close, volume)` returns a 1-D
`np.ndarray` with `NaN` for any prefix where the cumulative volume is
still `0`.
- **Node batch.** `vwap.batch(high, low, close, volume)` returns
`Array<number>` with `NaN` for the same prefix.
### Warmup
`warmup_period() == 1`. Provided the first candle has positive volume,
the indicator emits on tick 1. If the first `k` candles all have
`volume == 0`, no output is emitted until the first candle with
non-zero volume — `RollingVwap`'s warmup gating is independent of
this volume-gating logic and applies on top of it.
### Edge cases
- **Zero-volume bar.** A candle with `volume == 0` does not advance the
running sums in any visible way and (if it is the *first* such bar
the indicator has seen) keeps the output at `None`. The implementation
short-circuits with `if self.sum_v == 0.0 { return None; }`
(`vwap.rs:50`).
- **Constant input.** Identical candles produce a flat VWAP equal to
their typical price.
- **Session boundaries.** There is no automatic reset; the caller is
responsible for invoking `reset()` at the start of each new session.
- **NaN / infinity.** `Candle::new` rejects non-finite OHLCV values
before they can reach the indicator.
- **Reset.** `reset()` zeroes both running sums and unsets the `has_emitted`
flag.
### Examples
#### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, Vwap};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles = vec![
Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, 0)?, // tp = 10
Candle::new(20.0, 20.0, 20.0, 20.0, 3.0, 0)?, // tp = 20
Candle::new(30.0, 30.0, 30.0, 30.0, 1.0, 0)?, // tp = 30
Candle::new(40.0, 40.0, 40.0, 40.0, 2.0, 0)?, // tp = 40
];
let mut v = Vwap::new();
println!("{:?}", v.batch(&candles));
Ok(())
}
```
Output:
```
[Some(10.0), Some(17.5), Some(20.0), Some(25.714285714285715)]
```
Hand check at `t = 2`: `(10*1 + 20*3) / (1+3) = 70/4 = 17.5`.
At `t = 4`: `(10*1 + 20*3 + 30*1 + 40*2) / (1+3+1+2) = 180/7 ≈ 25.7142857`.
#### Python
```python
import numpy as np
import wickra as ta
vw = ta.VWAP()
h = np.array([10.0, 20.0, 30.0, 40.0])
l = np.array([10.0, 20.0, 30.0, 40.0])
c = np.array([10.0, 20.0, 30.0, 40.0])
v = np.array([ 1.0, 3.0, 1.0, 2.0])
print(vw.batch(h, l, c, v))
```
Output:
```
[10. 17.5 20. 25.71428571]
```
#### Node
```js
const w = require('wickra');
const vw = new w.VWAP();
console.log(vw.batch(
[10, 20, 30, 40],
[10, 20, 30, 40],
[10, 20, 30, 40],
[ 1, 3, 1, 2],
));
```
Output:
```
[ 10, 17.5, 20, 25.714285714285715 ]
```
---
## `RollingVwap` (finite window)
A rolling-window variant for streaming bots that want a finite-memory
fair-price benchmark instead of an unbounded session aggregate.
### Parameters
| Name | Type | Default | Constraint | Source |
|----------|---------|--------------|------------|----------------------------------------------|
| `period` | `usize` | (no default) | `> 0` | `RollingVwap::new` (`vwap.rs:89`) |
`period == 0` returns `Error::PeriodZero`. `RollingVwap` is exposed in
Rust only — Python's `VWAP` / Node's `VWAP` correspond to the cumulative
form.
### Inputs / Outputs
```rust
impl Indicator for RollingVwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64>;
fn warmup_period(&self) -> usize { self.period }
}
```
The window stores `(typical_price * volume, volume)` pairs and runs
incremental `sum_pv` / `sum_v` aggregates, so each `update` is O(1).
### Warmup
`warmup_period() == period`. The first `period - 1` candles return
`None`; the `period`-th candle emits the first value provided the rolling
volume sum is positive. If the entire window has `volume == 0`, the
indicator stays at `None`.
### Edge cases
- **Window slides.** Once `window.len() == period`, the oldest
`(pv, v)` pair is subtracted from the running sums before the new
pair is added.
- **Zero-volume window.** If every candle in the window has zero
volume, `sum_v == 0` and the indicator suppresses output until a
positive-volume candle is in scope.
- **Reset.** `reset()` clears the window and both running sums.
- **`is_ready()`.** Returns `true` only when the window is full **and**
`sum_v > 0` (`vwap.rs:138`).
### Examples
#### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, RollingVwap};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let candles = vec![
Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, 0)?,
Candle::new(20.0, 20.0, 20.0, 20.0, 3.0, 0)?,
Candle::new(30.0, 30.0, 30.0, 30.0, 1.0, 0)?,
Candle::new(40.0, 40.0, 40.0, 40.0, 2.0, 0)?,
];
let mut rv = RollingVwap::new(3)?;
println!("{:?}", rv.batch(&candles));
Ok(())
}
```
Output:
```
[None, None, Some(20.0), Some(28.333333333333332)]
```
Hand check at `t = 3` with window `[10@1, 20@3, 30@1]`:
`(10 + 60 + 30) / (1+3+1) = 100/5 = 20.0`.
At `t = 4` with window `[20@3, 30@1, 40@2]`:
`(60 + 30 + 80) / (3+1+2) = 170/6 ≈ 28.333`.
(`RollingVwap` is currently exposed only in the Rust API; the Python
`VWAP` and Node `VWAP` classes correspond to the cumulative form.)
## Interpretation
- **Execution benchmark.** "Beat VWAP" is the canonical buy-side
execution mandate: an aggressive algo that ends up paying *below*
VWAP on the day is considered to have earned alpha relative to a
passive participation strategy.
- **Mean reversion.** Intraday strategies often fade extensions away
from VWAP, treating the VWAP line as a magnet.
- **Trend filter.** Some systems trade only longs above VWAP and only
shorts below it; the line acts as a session-aware bias toggle.
## Common pitfalls
- **Forgetting to reset.** Call `reset()` at session start (or on each
new trading day) — otherwise you average yesterday's tape into
today's signal and the line drifts permanently behind current
price action.
- **Zero-volume warmup.** Several common data sources include
pre-session candles with `volume = 0` for "no print this minute".
Cumulative VWAP returns `None` until at least one positive-volume
candle has been seen; downstream code should treat `None` /
`NaN` / `null` as "not yet ready," not as "VWAP is zero."
- **Typical price vs close.** Wickra uses typical price
`(H + L + C) / 3`, not close. A naive implementation that uses
close will produce noticeably different numbers on bars with wide
intraday ranges.
## References
- The VWAP construct emerged in institutional execution literature in
the late 1980s and early 1990s; it has no single attributed
inventor. The textbook reference for its role as an execution
benchmark is Bertsimas & Lo, "Optimal control of execution costs,"
*Journal of Financial Markets*, 1998.
## See also
- [OBV](Indicator-Obv.md) — cumulative signed-volume measure that pairs
well with VWAP as a divergence flag.
- [MFI](../momentum/Indicator-Mfi.md) — money-flow oscillator that also blends
typical price with volume.
- [Bollinger Bands](../volatility/Indicator-BollingerBands.md) — non-volume volatility
envelope, often layered alongside VWAP on intraday charts.