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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 (0–100 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 70–80 % 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 H–L 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.
|
||||
@@ -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.
|
||||
@@ -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 3–5
|
||||
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.
|
||||
@@ -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. **Price–SMA 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user