E4: commit the documentation sources

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

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