F13c: restructure the indicator catalogue into eight families
The original taxonomy was four classical families plus a statistics group, with the F1-F12 expansion slotted in as sub-categories. This regroups the whole 71-indicator catalogue into eight top-level families, each with at least five members: Moving Averages (12), Momentum Oscillators (13), Trend & Directional (9), Price Oscillators (5), Volatility & Bands (12), Trailing Stops (5), Volume (9), Price Statistics (7). - Wiki: docs/wiki/indicators/ reorganised into eight family folders; all 71 indicator pages moved with `git mv`. Every internal cross-link is normalised to `../<family>/Indicator-X.md`, each page's `Family` field is set to its new family, and two pre-existing `../Indicator-Chaining.md` links (should have been `../../`) are corrected. A link check confirms every relative wiki link resolves. - Indicators-Overview.md fully rewritten around the eight families; Home.md indicator reference and the README family table follow suit. - Warmup-Periods.md gains the eight F13 indicators; CHANGELOG records the 46-indicator expansion (25 -> 71) and the eight-family taxonomy. - Tests: Node indicators.test.js and Python test_new_indicators.py cover all eight new indicators (Node 91/91, Python 117/117 green). cargo fmt + clippy (core/wickra/data/wasm/node) clean; 508 core tests, 25 data tests and 74 doctests green.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
# 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 | Trend & Directional |
|
||||
| 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](../momentum-oscillators/Indicator-Rsi.md) — shares Wilder smoothing.
|
||||
- [Indicator: Aroon](../trend-directional/Indicator-Aroon.md) — alternative trend-strength
|
||||
measure, range-based.
|
||||
- [Indicator: MacdIndicator](../trend-directional/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,205 @@
|
||||
# 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 | Trend & Directional |
|
||||
| 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](../trend-directional/Indicator-Adx.md) — alternative trend-strength
|
||||
measure with explicit `+DI` / `−DI` direction.
|
||||
- [Indicator: Stochastic](../momentum-oscillators/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,158 @@
|
||||
# AroonOscillator
|
||||
|
||||
> Aroon Oscillator — the single-line difference `AroonUp − AroonDown`,
|
||||
> condensing the two Aroon lines into one trend gauge.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Family | Trend & Directional |
|
||||
| Input type | `Candle` (uses `high`, `low`) |
|
||||
| Output type | `f64` |
|
||||
| Output range | `[−100, 100]` |
|
||||
| Default parameters | `period = 14` (Python) |
|
||||
| Warmup period | `period + 1` |
|
||||
| Interpretation | Positive = up-trend, negative = down-trend, near zero = range. |
|
||||
|
||||
## Formula
|
||||
|
||||
```
|
||||
AroonOscillator = AroonUp − AroonDown
|
||||
```
|
||||
|
||||
where [`Aroon`](../trend-directional/Indicator-Aroon.md) reports two `[0, 100]` lines measuring
|
||||
how recently the window's highest high and lowest low occurred. Their
|
||||
difference lives in `[−100, 100]`: strongly positive means the most recent
|
||||
high is much fresher than the most recent low (an up-trend); strongly
|
||||
negative is the mirror image; near zero means neither extreme is recent.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Name | Type | Default | Valid range | Description |
|
||||
|----------|---------|---------------|-------------|-------------|
|
||||
| `period` | `usize` | `14` (Python) | `>= 1` | Aroon lookback window. `0` errors with `Error::PeriodZero`. |
|
||||
|
||||
The Python binding defaults `period` to `14`.
|
||||
|
||||
## Inputs / Outputs
|
||||
|
||||
From `crates/wickra-core/src/indicators/aroon_oscillator.rs`:
|
||||
|
||||
```rust
|
||||
impl Indicator for AroonOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
// update(&mut self, input: Candle) -> Option<f64>
|
||||
}
|
||||
```
|
||||
|
||||
`AroonOscillator` is a **candle-input** indicator: it reads `high` and
|
||||
`low`. In Python the streaming `update` accepts a 6-tuple or a dict; the
|
||||
batch helper takes `high` and `low` numpy arrays. Node and WASM expose
|
||||
`update(high, low)` and `batch(high, low)`.
|
||||
|
||||
## Warmup
|
||||
|
||||
`AroonOscillator::new(period).warmup_period() == period + 1` — identical
|
||||
to the underlying `Aroon`, which needs a `period + 1`-bar window before
|
||||
the first reading.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Pure trend.** A series of fresh highs gives `AroonUp = 100`,
|
||||
`AroonDown = 0`, so the oscillator is `+100`; a series of fresh lows is
|
||||
`−100` (`pure_uptrend_yields_plus_100` /
|
||||
`pure_downtrend_yields_minus_100` pin this).
|
||||
- **Bounds.** The output is always within `[−100, 100]`
|
||||
(`output_stays_within_minus_100_and_100` pins this).
|
||||
- **Candle validation.** `Candle::new` rejects invalid bars before
|
||||
`update` ever sees them.
|
||||
- **Reset.** `osc.reset()` clears the underlying Aroon window.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Candle, Indicator, AroonOscillator};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut osc = AroonOscillator::new(5)?;
|
||||
// 30 bars, each a fresh high.
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 + f64::from(i);
|
||||
Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let out = osc.batch(&candles);
|
||||
println!("last = {:?}", out.last().unwrap());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
last = Some(100.0)
|
||||
```
|
||||
|
||||
Every bar is a fresh high and never a fresh low, so the oscillator pins at
|
||||
`+100`. This matches the `pure_uptrend_yields_plus_100` test in
|
||||
`crates/wickra-core/src/indicators/aroon_oscillator.rs`.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
osc = ta.AroonOscillator(14)
|
||||
high = np.arange(100.0, 140.0)
|
||||
low = high - 2.0
|
||||
print(osc.batch(high, low)[-1]) # steady uptrend -> 100
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
100.0
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const ta = require('wickra');
|
||||
const osc = new ta.AroonOscillator(14);
|
||||
const high = Array.from({ length: 40 }, (_, i) => 100 + i);
|
||||
const low = high.map((h) => h - 2);
|
||||
console.log(osc.batch(high, low).at(-1)); // 100
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
`AroonOscillator` is a compact trend gauge. The two canonical reads are
|
||||
the zero-line cross (`AroonUp` overtaking `AroonDown` or vice versa — a
|
||||
trend change) and the magnitude (values pinned near `±100` confirm a
|
||||
strong, uninterrupted trend; values oscillating near zero confirm a
|
||||
range). Use it where the two-line `Aroon` is more detail than you need.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Feeding it scalar prices.** It needs `high`/`low`; it takes a
|
||||
`Candle`, not an `f64`.
|
||||
- **Expecting the `[0, 100]` Aroon scale.** The oscillator is signed and
|
||||
spans `[−100, 100]`.
|
||||
|
||||
## References
|
||||
|
||||
Tushar Chande's Aroon system (1995); the oscillator is the standard
|
||||
`AroonUp − AroonDown` difference.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator-Aroon.md](../trend-directional/Indicator-Aroon.md) — the two-line indicator this
|
||||
collapses.
|
||||
- [Indicator-Adx.md](../trend-directional/Indicator-Adx.md) — another trend-strength gauge.
|
||||
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||
@@ -0,0 +1,146 @@
|
||||
# ChoppinessIndex
|
||||
|
||||
> Choppiness Index — is the market trending or just chopping sideways?
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Family | Trend & Directional |
|
||||
| Input type | `Candle` (uses `high`, `low`, `close`) |
|
||||
| Output type | `f64` |
|
||||
| Output range | `[0, 100]` (typical) |
|
||||
| Default parameters | `period = 14` (Python) |
|
||||
| Warmup period | `period` |
|
||||
| Interpretation | High = choppy/ranging, low = trending; `61.8` / `38.2` thresholds. |
|
||||
|
||||
## Formula
|
||||
|
||||
```
|
||||
CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n)
|
||||
```
|
||||
|
||||
The ratio compares the distance price *actually travelled* (the summed true
|
||||
range) with the *net ground it covered* (the high-low span of the window). A
|
||||
clean trend travels almost exactly its span, so the ratio is near `1` and `CI`
|
||||
near `0`; a choppy market criss-crosses far more than its span, so the ratio
|
||||
is large and `CI` climbs toward `100`. The conventional reading is `CI > 61.8`
|
||||
ranging, `CI < 38.2` trending.
|
||||
|
||||
## Parameters
|
||||
|
||||
`period` — the lookback window. Must be at least `2` (the `log10(period)`
|
||||
denominator is zero for `period == 1`). The Python binding defaults it to `14`.
|
||||
|
||||
## Inputs / Outputs
|
||||
|
||||
From `crates/wickra-core/src/indicators/choppiness_index.rs`:
|
||||
|
||||
```rust
|
||||
impl Indicator for ChoppinessIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
// update(&mut self, input: Candle) -> Option<f64>
|
||||
}
|
||||
```
|
||||
|
||||
`ChoppinessIndex` is a **candle-input** indicator that reads `high`, `low` and
|
||||
`close` (the close drives the true range across bar gaps). Python's streaming
|
||||
`update` accepts a 6-tuple or a dict; the batch helper takes `high`, `low`,
|
||||
`close` numpy arrays. Node and WASM expose `update(high, low, close)` and the
|
||||
matching `batch`.
|
||||
|
||||
## Warmup
|
||||
|
||||
`ChoppinessIndex::new(14).warmup_period() == 14`. The first value lands once
|
||||
the window holds a full `period` bars.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Flat window.** A window with `high == low` everywhere has a zero span;
|
||||
`CI` is defined as `100` (maximal choppiness).
|
||||
- **Steady trend.** A one-directional march reads well below `50`.
|
||||
- **`period < 2`.** Rejected at construction.
|
||||
- **Reset.** `ci.reset()` clears the true-range and high/low windows.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Candle, Indicator, ChoppinessIndex};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut ci = ChoppinessIndex::new(2)?;
|
||||
// Two H=11 L=9 C=10 bars: ΣTR = 4, span = 2 -> CI = 100·log10(2)/log10(2).
|
||||
let out = ci.batch(&[
|
||||
Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0)?,
|
||||
Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 1)?,
|
||||
]);
|
||||
println!("{:?}", out);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[None, Some(100.0)]
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
ci = ta.ChoppinessIndex(2)
|
||||
high = np.array([11.0, 11.0])
|
||||
low = np.array([9.0, 9.0])
|
||||
close = np.array([10.0, 10.0])
|
||||
print(ci.batch(high, low, close))
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[ nan 100.]
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const ta = require('wickra');
|
||||
const ci = new ta.ChoppinessIndex(2);
|
||||
console.log(ci.batch([11, 11], [9, 9], [10, 10]));
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[ NaN, 100 ]
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
The Choppiness Index is not directional — it does not say *which way* price is
|
||||
going, only *whether* it is going anywhere. Use it as a regime filter: above
|
||||
`61.8` favour mean-reversion / range tactics; below `38.2` favour
|
||||
trend-following. It pairs naturally with a directional indicator that picks
|
||||
the side once a trend is confirmed.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Expecting a direction.** It has none — combine it with a trend indicator.
|
||||
- **Tiny periods.** `period = 2` is allowed but noisy; `14` is conventional.
|
||||
|
||||
## References
|
||||
|
||||
E. W. Dreiss' Choppiness Index; the summed-true-range formulation here is the
|
||||
standard one.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator-VerticalHorizontalFilter.md](../trend-directional/Indicator-VerticalHorizontalFilter.md)
|
||||
— the same trending-vs-ranging question on an inverted scale.
|
||||
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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 | Trend & Directional |
|
||||
| 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](../momentum-oscillators/Indicator-Rsi.md) — bounded sibling oscillator, useful
|
||||
as a confirmation filter on top of MACD signals.
|
||||
- [Indicator: Trix](../trend-directional/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,172 @@
|
||||
# MassIndex
|
||||
|
||||
> Mass Index — Donald Dorsey's range-expansion indicator: it watches the
|
||||
> high–low range widen and contract to anticipate reversals.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Family | Trend & Directional |
|
||||
| Input type | `Candle` (uses `high`, `low`) |
|
||||
| Output type | `f64` |
|
||||
| Output range | `> 0`, oscillates around `sum_period` |
|
||||
| Default parameters | `(ema_period = 9, sum_period = 25)` (Python) |
|
||||
| Warmup period | `2·ema_period + sum_period − 2` |
|
||||
| Interpretation | A rise above `27` then fall below `26.5` flags a reversal. |
|
||||
|
||||
## Formula
|
||||
|
||||
```
|
||||
range_t = high_t − low_t
|
||||
single_t = EMA(range, ema_period)_t
|
||||
double_t = EMA(single, ema_period)_t
|
||||
ratio_t = single_t / double_t
|
||||
MassIndex = Σ ratio over sum_period
|
||||
```
|
||||
|
||||
The Mass Index ignores direction entirely — it tracks **volatility shape**.
|
||||
When the high–low range widens, the single EMA pulls ahead of the double
|
||||
EMA, the ratio climbs above `1`, and the windowed sum rises. Dorsey's
|
||||
"reversal bulge" is the classic pattern: the Mass Index rising above `27`
|
||||
and then falling back below `26.5` warns that a range expansion is about
|
||||
to resolve — often into a trend reversal.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Name | Type | Default | Valid range | Description |
|
||||
|--------------|---------|---------------|-------------|-------------|
|
||||
| `ema_period` | `usize` | `9` (Python) | `>= 1` | Period of both EMAs in the cascade. `0` errors with `Error::PeriodZero`. |
|
||||
| `sum_period` | `usize` | `25` (Python) | `>= 1` | Length of the summation window. |
|
||||
|
||||
The Python binding defaults the pair to `(9, 25)`. The `periods` property
|
||||
returns `(ema_period, sum_period)`.
|
||||
|
||||
## Inputs / Outputs
|
||||
|
||||
From `crates/wickra-core/src/indicators/mass_index.rs`:
|
||||
|
||||
```rust
|
||||
impl Indicator for MassIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
// update(&mut self, input: Candle) -> Option<f64>
|
||||
}
|
||||
```
|
||||
|
||||
`MassIndex` is a **candle-input** indicator: it reads `high` and `low`. In
|
||||
Python the streaming `update` accepts a 6-tuple or a dict; the batch
|
||||
helper takes `high` and `low` numpy arrays. Node and WASM expose
|
||||
`update(high, low)` and `batch(high, low)`.
|
||||
|
||||
## Warmup
|
||||
|
||||
`warmup_period() == 2·ema_period + sum_period − 2`. The first EMA seeds at
|
||||
input `ema_period`; the second EMA, stacked on it, seeds at
|
||||
`2·ema_period − 1`; the summation window then needs `sum_period` ratios.
|
||||
For the default `(9, 25)` that is `41` bars.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Constant range.** When every bar has the same high–low range, both
|
||||
EMAs converge to the same value, every ratio is `1`, and the Mass Index
|
||||
equals `sum_period` (`constant_range_sums_to_sum_period` pins this).
|
||||
- **Zero-range market.** A flat market (`high == low`) drives both EMAs to
|
||||
`0`; the `0 / 0` is guarded with the neutral ratio `1`, so the Mass
|
||||
Index again equals `sum_period`
|
||||
(`zero_range_market_sums_to_sum_period` pins this).
|
||||
- **Candle validation.** `Candle::new` rejects invalid bars upstream.
|
||||
- **Reset.** `mi.reset()` clears both EMAs, the window and the sum.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Candle, Indicator, MassIndex};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut mi = MassIndex::new(3, 4)?;
|
||||
// Constant high-low range of 2.0; the Mass Index settles at sum_period.
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + f64::from(i);
|
||||
Candle::new(mid, mid + 1.0, mid - 1.0, mid, 1.0, i64::from(i)).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let out = mi.batch(&candles);
|
||||
println!("warmup_period = {}", mi.warmup_period());
|
||||
println!("last = {:?}", out.last().unwrap());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
warmup_period = 8
|
||||
last = Some(4.0)
|
||||
```
|
||||
|
||||
A constant range makes every ratio `1`, so the sum equals `sum_period`
|
||||
(`4`). This matches the `constant_range_sums_to_sum_period` test in
|
||||
`crates/wickra-core/src/indicators/mass_index.rs`.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
mi = ta.MassIndex() # (ema_period=9, sum_period=25)
|
||||
mid = np.arange(100.0, 160.0)
|
||||
high = mid + 1.0
|
||||
low = mid - 1.0
|
||||
print(mi.batch(high, low)[-1]) # constant range -> 25
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
25.0
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const ta = require('wickra');
|
||||
const mi = new ta.MassIndex(9, 25);
|
||||
const mid = Array.from({ length: 60 }, (_, i) => 100 + i);
|
||||
const high = mid.map((m) => m + 1);
|
||||
const low = mid.map((m) => m - 1);
|
||||
console.log(mi.batch(high, low).at(-1)); // 25
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
`MassIndex` is a *reversal-warning* tool, not a direction tool — it never
|
||||
tells you which way price will go, only that a turn is likely. The textbook
|
||||
use is the "reversal bulge" on the default `(9, 25)` settings: watch for
|
||||
the index to push above `27`, then act when it drops back under `26.5`,
|
||||
using a directional indicator (a moving average, ADX) to pick the side.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Expecting a direction.** The Mass Index is direction-blind; always
|
||||
pair it with a trend indicator.
|
||||
- **Feeding it scalar prices.** It needs `high`/`low`; it takes a
|
||||
`Candle`, not an `f64`.
|
||||
|
||||
## References
|
||||
|
||||
Donald Dorsey, "The Mass Index", *Technical Analysis of Stocks &
|
||||
Commodities* (1992). The double-EMA-of-range construction and the `(9,
|
||||
25)` defaults follow Dorsey's original.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator-Atr.md](../volatility-bands/Indicator-Atr.md) — directional-free
|
||||
volatility in price units.
|
||||
- [Indicator-BollingerBands.md](../volatility-bands/Indicator-BollingerBands.md)
|
||||
— another range-expansion lens.
|
||||
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||
@@ -0,0 +1,186 @@
|
||||
# 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 | Trend & Directional |
|
||||
| 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](../trend-directional/Indicator-MacdIndicator.md) — faster
|
||||
EMA-based momentum oscillator, useful as a confirmation against
|
||||
TRIX zero-line crosses.
|
||||
- [Indicator: Roc](../momentum-oscillators/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,140 @@
|
||||
# VerticalHorizontalFilter
|
||||
|
||||
> Vertical Horizontal Filter (VHF) — net distance covered divided by total
|
||||
> distance walked; a trend-versus-range gauge.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Family | Trend & Directional |
|
||||
| Input type | `f64` (close price) |
|
||||
| Output type | `f64` |
|
||||
| Output range | `[0, 1]` |
|
||||
| Default parameters | `period = 28` (Python) |
|
||||
| Warmup period | `period + 1` |
|
||||
| Interpretation | Near `1` = trending, near `0` = choppy. |
|
||||
|
||||
## Formula
|
||||
|
||||
```
|
||||
VHF = (highest_close(n) − lowest_close(n)) / Σ|close − close_prev|(n)
|
||||
```
|
||||
|
||||
The numerator is the *net* distance price covered over the window; the
|
||||
denominator is the *total* distance it walked. Their ratio lives in `[0, 1]`:
|
||||
a clean trend walks almost only in its net direction, so `VHF` approaches `1`;
|
||||
a choppy market doubles back constantly, inflating the denominator and pushing
|
||||
`VHF` toward `0`. It answers the same question as the
|
||||
[`ChoppinessIndex`](../trend-directional/Indicator-ChoppinessIndex.md) on an inverted scale.
|
||||
|
||||
## Parameters
|
||||
|
||||
`period` — the lookback window. The Python binding defaults it to `28`; the
|
||||
Rust and Node constructors require it explicitly.
|
||||
|
||||
## Inputs / Outputs
|
||||
|
||||
From `crates/wickra-core/src/indicators/vertical_horizontal_filter.rs`:
|
||||
|
||||
```rust
|
||||
impl Indicator for VerticalHorizontalFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
// update(&mut self, input: f64) -> Option<f64>
|
||||
}
|
||||
```
|
||||
|
||||
`VerticalHorizontalFilter` is a **scalar** indicator: it consumes one `f64`
|
||||
close per step. Because `Input = f64` it can sit inside a
|
||||
[`Chain`](../../Indicator-Chaining.md).
|
||||
|
||||
## Warmup
|
||||
|
||||
`VerticalHorizontalFilter::new(28).warmup_period() == 29`. The high/low window
|
||||
fills at `period` closes, but the `period`-th difference needs one extra input
|
||||
because the first close has nothing to diff against.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Flat series.** A window that walked nowhere has a zero denominator; `VHF`
|
||||
is defined as `0`.
|
||||
- **Pure trend.** A series rising by a fixed step reads `(period − 1) / period`.
|
||||
- **Choppy series.** An oscillating series reads near `0`.
|
||||
- **Reset.** `vhf.reset()` clears the close and difference windows.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Indicator, VerticalHorizontalFilter};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut vhf = VerticalHorizontalFilter::new(5)?;
|
||||
// Closes 1..6: each diff is 1 (Σ = 5), the 5-close span is 4 -> 4/5.
|
||||
let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
println!("{:?}", out);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[None, None, None, None, None, Some(0.8)]
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
vhf = ta.VerticalHorizontalFilter(5)
|
||||
print(vhf.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])))
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[ nan nan nan nan nan 0.8]
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const ta = require('wickra');
|
||||
const vhf = new ta.VerticalHorizontalFilter(5);
|
||||
console.log(vhf.batch([1, 2, 3, 4, 5, 6]));
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[ NaN, NaN, NaN, NaN, NaN, 0.8 ]
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
Use the VHF as a regime filter: a high, rising VHF says a trend is in force —
|
||||
favour trend-following entries; a low VHF says price is ranging — favour
|
||||
mean-reversion. A VHF turning down from a high level is an early hint the
|
||||
trend is losing its grip.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Expecting a direction.** Like the Choppiness Index it is non-directional —
|
||||
pair it with a trend indicator.
|
||||
- **Reading a single bar.** It is a regime gauge; read its level and slope.
|
||||
|
||||
## References
|
||||
|
||||
Adam White's Vertical Horizontal Filter; the net-over-total formulation here
|
||||
is the standard one.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator-ChoppinessIndex.md](../trend-directional/Indicator-ChoppinessIndex.md) — the same
|
||||
trending-vs-ranging question on an inverted `[0, 100]` scale.
|
||||
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Vortex
|
||||
|
||||
> Vortex Indicator — a pair of oscillators (`VI+`, `VI−`) whose crossings
|
||||
> identify the start of a new trend.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Family | Trend & Directional |
|
||||
| Input type | `Candle` (uses `high`, `low`, `close`) |
|
||||
| Output type | `VortexOutput { plus, minus }` |
|
||||
| Output range | each line `>= 0`, typically around `1.0` |
|
||||
| Default parameters | `period = 14` (Python) |
|
||||
| Warmup period | `period + 1` |
|
||||
| Interpretation | `VI+` above `VI−` = up-trend; the cross marks the turn. |
|
||||
|
||||
## Formula
|
||||
|
||||
```
|
||||
VM+_t = |high_t − low_{t−1}| (positive vortex movement)
|
||||
VM−_t = |low_t − high_{t−1}| (negative vortex movement)
|
||||
TR_t = true range
|
||||
VI+ = Σ VM+ over period / Σ TR over period
|
||||
VI− = Σ VM− over period / Σ TR over period
|
||||
```
|
||||
|
||||
Each vortex movement measures how far this bar reached against the
|
||||
*opposite* extreme of the previous bar; dividing the running sums by the
|
||||
running true range normalises both lines to a comparable scale around
|
||||
`1.0`. `VI+` crossing above `VI−` signals a new up-trend; the reverse, a
|
||||
down-trend.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Name | Type | Default | Valid range | Description |
|
||||
|----------|---------|---------------|-------------|-------------|
|
||||
| `period` | `usize` | `14` (Python) | `>= 1` | Summation window. `0` errors with `Error::PeriodZero`. |
|
||||
|
||||
The Python binding defaults `period` to `14`.
|
||||
|
||||
## Inputs / Outputs
|
||||
|
||||
From `crates/wickra-core/src/indicators/vortex.rs`:
|
||||
|
||||
```rust
|
||||
pub struct VortexOutput { pub plus: f64, pub minus: f64 }
|
||||
|
||||
impl Indicator for Vortex {
|
||||
type Input = Candle;
|
||||
type Output = VortexOutput;
|
||||
}
|
||||
```
|
||||
|
||||
`Vortex` is a **candle-input** indicator reading `high`, `low` and
|
||||
`close`. The streaming `update` returns `VortexOutput` (Rust),
|
||||
`(plus, minus)` (Python), or `{ plus, minus }` (Node/WASM). The batch
|
||||
helper returns one row per input — a `(n, 2)` numpy array in Python, a
|
||||
flat `[plus, minus, …]` array of length `2·n` in Node/WASM, with `NaN`
|
||||
during warmup.
|
||||
|
||||
## Warmup
|
||||
|
||||
`Vortex::new(period).warmup_period() == period + 1`. The first VM/TR
|
||||
triple needs a previous bar, so it forms on bar 2; the summation window
|
||||
then needs `period` triples — the first output lands on input
|
||||
`period + 1`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Flat market.** A window with zero total true range cannot be
|
||||
normalised; both lines are reported as `0.0`
|
||||
(`perfectly_flat_market_yields_zero` pins this).
|
||||
- **Non-negative.** Both `VI+` and `VI−` are sums of absolute values over
|
||||
a non-negative range, so neither is ever negative
|
||||
(`outputs_are_non_negative` pins this).
|
||||
- **Candle validation.** `Candle::new` rejects invalid bars upstream.
|
||||
- **Reset.** `vortex.reset()` clears the previous bar, the window and the
|
||||
three running sums.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Candle, Indicator, Vortex};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let candles = [
|
||||
Candle::new(9.0, 10.0, 8.0, 9.0, 1.0, 0)?,
|
||||
Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 1)?,
|
||||
Candle::new(12.0, 13.0, 11.0, 12.0, 1.0, 2)?,
|
||||
];
|
||||
let mut v = Vortex::new(2)?;
|
||||
let out = v.batch(&candles);
|
||||
println!("{:?}", out[2]);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Some(VortexOutput { plus: 1.6, minus: 0.4 })
|
||||
```
|
||||
|
||||
Over the two formed bars `Σ VM+ = 8`, `Σ VM− = 2`, `Σ TR = 5`, giving
|
||||
`VI+ = 1.6` and `VI− = 0.4`. This matches the `reference_values` test in
|
||||
`crates/wickra-core/src/indicators/vortex.rs`.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
v = ta.Vortex(14)
|
||||
high = np.array([10.0, 12.0, 13.0])
|
||||
low = np.array([8.0, 9.0, 11.0])
|
||||
close = np.array([9.0, 11.0, 12.0])
|
||||
# v.batch(high, low, close) -> (3, 2) array of [plus, minus], NaN during warmup
|
||||
print(v.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)))
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const ta = require('wickra');
|
||||
const v = new ta.Vortex(14);
|
||||
console.log(v.update(12, 9, 11)); // { plus, minus } or null during warmup
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
`Vortex` is a trend-onset detector. The signal is the **crossing**: when
|
||||
`VI+` rises above `VI−`, a new up-trend is starting; when `VI−` rises
|
||||
above `VI+`, a down-trend. The gap between the lines measures conviction —
|
||||
a wide, widening gap is a strong trend, converging lines warn of a stall.
|
||||
Unlike a lagging moving-average cross, the vortex movements react to the
|
||||
*reach* of each bar, so the cross tends to fire early.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Reading the lines in isolation.** A `VI+` of `1.1` means nothing on
|
||||
its own — what matters is its position relative to `VI−`.
|
||||
- **Feeding it scalar prices.** It needs `high`/`low`/`close`.
|
||||
|
||||
## References
|
||||
|
||||
Etienne Botes and Douglas Siepman, "The Vortex Indicator", *Technical
|
||||
Analysis of Stocks & Commodities* (2010). The `VM±` / true-range
|
||||
definition here follows their original.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator-Adx.md](../trend-directional/Indicator-Adx.md) — Wilder's directional system.
|
||||
- [Indicator-Atr.md](../volatility-bands/Indicator-Atr.md) — the true range
|
||||
Vortex normalises against.
|
||||
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||
Reference in New Issue
Block a user