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:
kingchenc
2026-05-22 21:21:56 +02:00
parent 6643f7a81d
commit d2f99efd78
78 changed files with 612 additions and 616 deletions
@@ -0,0 +1,195 @@
# 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 Oscillators |
| 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](../trend-directional/Indicator-MacdIndicator.md) — sister
oscillator on closes (with an extra signal line on top).
- [Indicator: Trix](../trend-directional/Indicator-Trix.md) — momentum oscillator on a
triple-smoothed series.
- [Warmup Periods](../../Warmup-Periods.md) — bare `slow_period`.
@@ -0,0 +1,198 @@
# 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 Oscillators |
| 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](../momentum-oscillators/Indicator-Rsi.md) — bounded sibling for comparison.
- [Indicator: WilliamsR](../momentum-oscillators/Indicator-WilliamsR.md) — another candle-input
oscillator, range-based rather than deviation-based.
- [Indicator: Mfi](../momentum-oscillators/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,155 @@
# CMO
> Chande Momentum Oscillator — a bounded `[100, 100]` momentum gauge from
> the unsmoothed sum of gains versus losses.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | `[100, 100]` |
| Default parameters | `period = 14` (Python) |
| Warmup period | `period + 1` |
| Interpretation | `+100` pure gains, `100` pure losses, `0` balanced. |
## Formula
Over the last `period` price *changes*, sum the gains and the losses
separately:
```
gain_t = max(price_t price_{t1}, 0)
loss_t = max(price_{t1} price_t, 0)
CMO = 100 · (Σ gain Σ loss) / (Σ gain + Σ loss)
```
Unlike RSI — which Wilder-smooths the gain/loss averages — CMO sums them
raw, with equal weight on every change in the window. That makes it
faster and wider-swinging than RSI at the same period.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|---------------|-------------|-------------|
| `period` | `usize` | `14` (Python) | `>= 1` | Number of price changes summed. `period = 0` errors with `Error::PeriodZero`. |
The Python binding defaults `period` to `14` via `#[pyo3(signature = (period=14))]`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/cmo.rs`:
```rust
impl Indicator for Cmo {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
A single `f64` close in, an `Option<f64>` out. Python maps this to
`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` /
`Array<number>` (NaN warmup).
## Warmup
`Cmo::new(period).warmup_period() == period + 1`. The first price change
needs two inputs, and the gain/loss window must hold `period` changes, so
the first non-`None` output lands on input `period + 1`.
## Edge cases
- **Pure trend.** A window of only gains returns `+100`; only losses,
`100` (`pure_uptrend_saturates_at_plus_100` /
`pure_downtrend_saturates_at_minus_100` pin this).
- **Constant series.** A flat series has no gains and no losses; the
`0 / 0` is guarded and the output is `0.0`
(`constant_series_yields_zero` pins this).
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; state
is left untouched.
- **Reset.** `cmo.reset()` clears the previous price, the gain/loss window
and both running sums.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Cmo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut cmo = Cmo::new(3)?;
let out: Vec<Option<f64>> = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, None, None, Some(50.0)]
```
The three changes are `+1, 1, +2`: `Σ gain = 3`, `Σ loss = 1`, so
`CMO = 100·(3 1)/(3 + 1) = 50`. This matches the `reference_value` test
in `crates/wickra-core/src/indicators/cmo.rs`.
### Python
```python
import numpy as np
import wickra as ta
cmo = ta.CMO(3)
print(cmo.batch(np.array([10.0, 11.0, 10.0, 12.0])))
```
Output:
```
[nan nan nan 50.]
```
### Node
```javascript
const ta = require('wickra');
const cmo = new ta.CMO(3);
console.log(cmo.batch([10, 11, 10, 12]));
```
Output:
```
[ NaN, NaN, NaN, 50 ]
```
## Interpretation
`Cmo` is read like other bounded oscillators: readings near `+50` and
above flag overbought conditions, near `50` and below oversold, and the
zero line marks the gain/loss balance point. Because it is unsmoothed it
reacts a bar or two sooner than RSI but is noisier — pair it with a slower
filter, or use it for divergence rather than raw threshold triggers.
## Common pitfalls
- **Expecting the `[0, 100]` RSI scale.** `Cmo` is centred on zero and
spans `[100, 100]`; an RSI of `30` corresponds to a `Cmo` near `40`.
- **Treating it as a smoothed average.** `Cmo` sums raw changes — it is
deliberately not Wilder-smoothed.
## References
Tushar Chande, *The New Technical Trader* (1994). The unsmoothed
gain/loss sum here matches the original definition and TA-Lib's `CMO`.
## See also
- [Indicator-Rsi.md](../momentum-oscillators/Indicator-Rsi.md) — the Wilder-smoothed relative.
- [Indicator-Mom.md](../momentum-oscillators/Indicator-Mom.md) — raw price-difference momentum.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,203 @@
# MFI
> Money Flow Index — a volume-weighted RSI built on typical price times
> volume.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| 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](../momentum-oscillators/Indicator-Rsi.md) — the price-only ancestor.
- [Indicator: Adx](../trend-directional/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,151 @@
# MOM
> Momentum — the raw price change over a fixed lookback,
> `price_t price_{tperiod}`, in absolute price units.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded around zero (price-difference scale) |
| Default parameters | `period = 10` (Python) |
| Warmup period | `period + 1` |
| Interpretation | Sign and size of the move over the last `period` bars. |
## Formula
```
MOM_t = price_t price_{tperiod}
```
The simplest momentum primitive. Positive output means price is higher
than it was `period` bars ago, negative means lower, and the magnitude is
the change in raw price units. [`Roc`](../momentum-oscillators/Indicator-Roc.md) is the same idea
expressed as a percentage of the old price.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|----------------|-------------|-------------|
| `period` | `usize` | `10` (Python) | `>= 1` | Lookback distance in bars. `period = 0` errors with `Error::PeriodZero`. |
The Python binding defaults `period` to `10` via `#[pyo3(signature = (period=10))]`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/mom.rs`:
```rust
impl Indicator for Mom {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
A single `f64` close in, an `Option<f64>` out. Python maps this to
`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` /
`Array<number>` (NaN warmup).
## Warmup
`Mom::new(period).warmup_period() == period + 1`. The output needs both
the current price and the price `period` bars back, so the window must
hold `period + 1` values — the first non-`None` output lands on input
`period + 1`.
## Edge cases
- **Constant series.** A flat series yields `0.0` from input `period + 1`
onward (`constant_series_yields_zero` pins this).
- **NaN / infinity inputs.** Non-finite inputs are silently dropped: the
rolling window is not advanced and the previous value is returned. The
next finite input still references the correct historical price.
- **Reset.** `mom.reset()` clears the window and restarts the warmup.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Mom};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut mom = Mom::new(3)?;
let out: Vec<Option<f64>> = mom.batch(&[1.0, 2.0, 3.0, 4.0, 7.0]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, None, None, Some(3.0), Some(5.0)]
```
`MOM(3)` first emits on input 4: `4 1 = 3`. The fifth input gives
`7 2 = 5`. This matches the `reference_values` test in
`crates/wickra-core/src/indicators/mom.rs`.
### Python
```python
import numpy as np
import wickra as ta
mom = ta.MOM(3)
print(mom.batch(np.array([1.0, 2.0, 3.0, 4.0, 7.0])))
```
Output:
```
[nan nan nan 3. 5.]
```
### Node
```javascript
const ta = require('wickra');
const mom = new ta.MOM(3);
console.log(mom.batch([1, 2, 3, 4, 7]));
```
Output:
```
[ NaN, NaN, NaN, 3, 5 ]
```
## Interpretation
`Mom` is a zero-centred oscillator. The textbook reads are the zero-line
cross (momentum flipping sign) and divergence (price making a new high
while `Mom` makes a lower high — a stalling trend). Because the output is
in price units, `Mom` values are not comparable across instruments at
different price levels; use [`Roc`](../momentum-oscillators/Indicator-Roc.md) when you need a
scale-free percentage instead.
## Common pitfalls
- **Comparing `Mom` across instruments.** A `Mom` of `5` means very
different things on a $10 stock and a $5000 index. Normalise with `Roc`
for cross-asset work.
- **Forgetting the `+1` warmup.** `warmup_period()` is `period + 1`, not
`period`.
## References
Momentum is one of the oldest technical studies; the implementation here
is the standard `price price[period]` difference, matching TA-Lib's
`MOM`.
## See also
- [Indicator-Roc.md](../momentum-oscillators/Indicator-Roc.md) — the percentage-scaled counterpart.
- [Indicator-Cmo.md](../momentum-oscillators/Indicator-Cmo.md) — bounded momentum from summed changes.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,169 @@
# PMO
> Price Momentum Oscillator — Carl Swenlin's DecisionPoint PMO line: a
> doubly-smoothed rate of change.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded around zero |
| Default parameters | `(smoothing1 = 35, smoothing2 = 20)` (Python) |
| Warmup period | `2` |
| Interpretation | Smoothed momentum; zero-line and signal-line crosses are the signals. |
## Formula
```
roc_t = (price_t / price_{t1} 1) · 100
smoothed_t = customEMA(roc, smoothing1)_t
PMO_t = customEMA(10 · smoothed, smoothing2)_t
```
`customEMA` is the DecisionPoint smoothing: an exponential average whose
smoothing constant is `2 / period` (not the textbook `2 / (period + 1)`),
seeded from its first input. The 1-bar percentage change is smoothed once,
scaled by `10`, then smoothed again.
The classic PMO **signal line** is a 10-period EMA of this PMO line. It is
deliberately not bundled in — compose it yourself with
[`Chain`](../../Indicator-Chaining.md) and an `Ema(10)`.
## Parameters
| Name | Type | Default | Valid range | Description |
|--------------|---------|---------------|-------------|-------------|
| `smoothing1` | `usize` | `35` (Python) | `>= 2` | First smoothing period (applied to ROC). `0` errors with `Error::PeriodZero`; `1` with `Error::InvalidPeriod`. |
| `smoothing2` | `usize` | `20` (Python) | `>= 2` | Second smoothing period (applied to `10 · smoothed`). Same error rules. |
`smoothing = 1` is rejected because the smoothing constant `2 / 1 = 2`
would exceed `1`. The Python binding defaults the pair to `(35, 20)` via
`#[pyo3(signature = (smoothing1=35, smoothing2=20))]`. The `periods`
property returns `(smoothing1, smoothing2)`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/pmo.rs`:
```rust
impl Indicator for Pmo {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
A single `f64` close in, an `Option<f64>` out. Python maps this to
`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` /
`Array<number>` (NaN warmup).
## Warmup
`Pmo::new(s1, s2).warmup_period() == 2`. The first ROC needs a previous
price, and both `customEMA`s seed from their very first input, so the
first non-`None` output lands on the **second** `update()`. Note this is
the first *defined* value; the doubly-smoothed series only stabilises
after many more bars, so treat early readings as unsettled.
## Edge cases
- **Constant series.** A flat series gives `roc = 0` on every bar, so both
smoothings stay at `0` and PMO is `0.0`
(`constant_series_yields_zero` pins this).
- **Zero previous price.** A ratio against a `0.0` prior price is
undefined; `roc` is treated as `0` for that bar.
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the
smoothing chains are not advanced.
- **Reset.** `pmo.reset()` clears the previous price and both EMAs.
## Examples
### Rust
```rust
use wickra::{Indicator, Pmo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut pmo = Pmo::new(35, 20)?;
println!("{:?}", pmo.update(100.0)); // no previous price yet
println!("{:?}", pmo.update(101.0)); // first defined PMO
Ok(())
}
```
Output:
```
None
Some(10.0)
```
The first `update` only records the price. The second produces
`roc = 1.0%`; each `customEMA` seeds from its first input, so the inner
EMA emits `1.0`, the `×10` scaling gives `10.0`, and the outer EMA seeds
at `10.0` — hence `PMO = 10.0` on the first defined bar. Early values are
seed artefacts: the double smoothing only settles after many more bars.
This matches the `first_emission_at_second_update` test in
`crates/wickra-core/src/indicators/pmo.rs`.
### Python
```python
import numpy as np
import wickra as ta
pmo = ta.PMO() # (smoothing1=35, smoothing2=20)
prices = 100.0 * 1.01 ** np.arange(120) # steady uptrend
out = pmo.batch(prices)
print("last > 0:", out[-1] > 0)
```
Output:
```
last > 0: True
```
### Node
```javascript
const ta = require('wickra');
const pmo = new ta.PMO(35, 20);
const prices = Array.from({ length: 120 }, (_, i) => 100 * 1.01 ** i);
console.log('last:', pmo.batch(prices).at(-1));
```
## Interpretation
`Pmo` is a smoothed momentum line. The DecisionPoint reads are: PMO
crossing its zero line (momentum changing sign), PMO crossing its signal
line (a 10-EMA of PMO — build it with `Chain`), and PMO turning up/down
from an extreme. Because the rate of change is taken in percentage terms,
PMO values *are* comparable across instruments — unlike raw
[`Mom`](../momentum-oscillators/Indicator-Mom.md).
## Common pitfalls
- **Trusting the first few values.** `warmup_period()` is `2`, but that is
only the first *defined* output — the double smoothing needs many bars
to settle. Discard the early ramp.
- **Expecting a bundled signal line.** PMO here is the single PMO line;
add `Ema(10)` via `Chain` for the signal.
## References
Carl Swenlin, DecisionPoint Price Momentum Oscillator. The
`2 / period` "custom smoothing", the `×10` scaling and the conventional
`(35, 20)` periods follow the published DecisionPoint definition.
## See also
- [Indicator-Roc.md](../momentum-oscillators/Indicator-Roc.md) — the raw rate of change PMO smooths.
- [Indicator-Tsi.md](../momentum-oscillators/Indicator-Tsi.md) — another double-smoothed momentum
oscillator.
- [Indicator-Chaining.md](../../Indicator-Chaining.md) — how to add the
signal-line EMA.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,172 @@
# ROC
> Rate of Change — the percent change between the current close and the
> close `period` bars ago.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| 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](../momentum-oscillators/Indicator-Rsi.md) — same `period + 1` warmup, but
bounded.
- [Indicator: Trix](../trend-directional/Indicator-Trix.md) — also a rate of change, but on
a triple-smoothed EMA.
- [Indicator: MacdIndicator](../trend-directional/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,213 @@
# 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 Oscillators |
| 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](../trend-directional/Indicator-MacdIndicator.md) — also momentum,
but trend-following and unbounded.
- [Indicator: Stochastic](../momentum-oscillators/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,164 @@
# StochRSI
> Stochastic RSI — the Stochastic Oscillator formula applied to the RSI
> series, sharpening RSI's overbought/oversold turns.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | `[0, 100]` |
| Default parameters | `(rsi_period = 14, stoch_period = 14)` (Python) |
| Warmup period | `rsi_period + stoch_period` |
| Interpretation | Where RSI sits in its own recent range; near `0`/`100` = extremes. |
## Formula
```
RSI_t = Rsi(rsi_period) of price
StochRSI = 100 · (RSI_t min(RSI, stoch_period)) / (max(RSI, …) min(RSI, …))
```
RSI rarely visits its `0`/`100` extremes — it spends most of its life
bunched around the middle. StochRSI re-normalises it: it asks where the
*current* RSI sits within its own high/low range over the last
`stoch_period` bars. The result swings the full `[0, 100]` width far more
often than raw RSI, so reversals are easier to spot.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------------|---------|---------------|-------------|-------------|
| `rsi_period` | `usize` | `14` (Python) | `>= 1` | Period of the underlying RSI. `0` errors with `Error::PeriodZero`. |
| `stoch_period` | `usize` | `14` (Python) | `>= 1` | Lookback for the high/low range of RSI. `0` errors with `Error::PeriodZero`. |
The Python binding defaults the pair to `(14, 14)` via
`#[pyo3(signature = (rsi_period=14, stoch_period=14))]`. Node and WASM
take both explicitly. The `periods` property returns
`(rsi_period, stoch_period)`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/stoch_rsi.rs`:
```rust
impl Indicator for StochRsi {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
A single `f64` close in, an `Option<f64>` out. Python maps this to
`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` /
`Array<number>` (NaN warmup).
## Warmup
`StochRsi::new(rsi_period, stoch_period).warmup_period()
== rsi_period + stoch_period`. The inner RSI emits its first value on
input `rsi_period + 1`; the stochastic window then needs `stoch_period`
RSI values, so the first non-`None` output lands on input
`rsi_period + stoch_period`.
## Edge cases
- **Flat RSI window.** When every RSI value in the window is equal — for
example a constant price (RSI pinned at `50`) or a pure trend (RSI
pinned at `100`) — the range is zero and StochRSI reports the neutral
`50.0` (`flat_rsi_window_yields_50` and `pure_uptrend_yields_50` pin
this).
- **Bounds.** The output is always within `[0, 100]`
(`output_stays_within_0_100` pins this).
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the
RSI and the window are not advanced.
- **Reset.** `stoch_rsi.reset()` clears the inner RSI and the window.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, StochRsi};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut sr = StochRsi::new(14, 14)?;
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let out = sr.batch(&prices);
println!("warmup_period = {}", sr.warmup_period());
println!("ready values: {}", out.iter().flatten().count());
Ok(())
}
```
Output:
```
warmup_period = 28
ready values: 33
```
The first 27 inputs return `None`; from input 28 onward every output is a
defined `[0, 100]` value.
### Python
```python
import numpy as np
import wickra as ta
sr = ta.StochRSI() # (rsi_period=14, stoch_period=14)
prices = np.full(40, 100.0) # constant series
print(sr.batch(prices)[-1]) # flat RSI window -> neutral 50
```
Output:
```
50.0
```
### Node
```javascript
const ta = require('wickra');
const sr = new ta.StochRSI(14, 14);
const prices = Array.from({ length: 60 }, (_, i) => 100 + Math.sin(i * 0.3) * 10);
console.log('warmupPeriod:', sr.warmupPeriod());
```
## Interpretation
`StochRsi` is read like any `[0, 100]` oscillator, but with tighter
thresholds because it saturates so readily: above `80` is overbought,
below `20` oversold, and the `50` line is the midpoint. Because it is two
oscillators deep, it is *fast and noisy* — excellent for spotting
short-term turns, poor as a standalone trend filter. Many traders smooth
it further (an SMA of StochRSI) and trade the crossover.
## Common pitfalls
- **Using it as a trend filter.** `StochRsi` whipsaws; confirm with a
slower indicator before acting on a raw threshold cross.
- **Forgetting the stacked warmup.** Warmup is `rsi_period + stoch_period`
— for the default `(14, 14)` that is 28 bars.
- **Expecting raw-RSI values.** `StochRsi` is a *position within range*,
not RSI itself; the two are not interchangeable.
## References
Tushar Chande and Stanley Kroll, *The New Technical Trader* (1994). The
implementation is the standard Stochastic-of-RSI; the flat-window
convention (`50`) matches this library's [`Stochastic`](../momentum-oscillators/Indicator-Stochastic.md).
## See also
- [Indicator-Rsi.md](../momentum-oscillators/Indicator-Rsi.md) — the underlying oscillator.
- [Indicator-Stochastic.md](../momentum-oscillators/Indicator-Stochastic.md) — the same formula on
price instead of RSI.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,219 @@
# 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 Oscillators |
| 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](../momentum-oscillators/Indicator-Rsi.md) — sister bounded oscillator, slower
and smoother than `%K`.
- [Indicator: WilliamsR](../momentum-oscillators/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,159 @@
# TSI
> True Strength Index — a double-smoothed momentum oscillator that strips
> noise while keeping a clean, zero-centred read on trend pressure.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | roughly `[100, 100]`, centred on zero |
| Default parameters | `(long = 25, short = 13)` (Python) |
| Warmup period | `long + short` |
| Interpretation | Positive = net upward pressure, negative = net downward. |
## Formula
```
momentum_t = price_t price_{t1}
TSI = 100 · EMA_short(EMA_long(momentum)) / EMA_short(EMA_long(|momentum|))
```
The 1-bar momentum and its absolute value are each smoothed twice — first
with an EMA of length `long`, then with an EMA of length `short`. The
ratio of the two double-smoothed series normalises the result: when every
recent move is up, numerator and denominator are equal and TSI saturates
at `+100`; when every move is down, at `100`.
## Parameters
| Name | Type | Default | Valid range | Description |
|---------|---------|---------------|-------------|-------------|
| `long` | `usize` | `25` (Python) | `>= 1` | First (slow) smoothing length. `0` errors with `Error::PeriodZero`. |
| `short` | `usize` | `13` (Python) | `>= 1` | Second (fast) smoothing length. `0` errors with `Error::PeriodZero`. |
The Python binding defaults the pair to `(25, 13)` via
`#[pyo3(signature = (long=25, short=13))]`. Node and WASM take both
explicitly. The `periods` property returns `(long, short)`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/tsi.rs`:
```rust
impl Indicator for Tsi {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
A single `f64` close in, an `Option<f64>` out. Python maps this to
`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` /
`Array<number>` (NaN warmup).
## Warmup
`Tsi::new(long, short).warmup_period() == long + short`. The momentum
series starts on input 2; the SMA-seeded `long` EMA seeds at input
`long + 1`, and the `short` EMA stacked on top seeds `short 1` inputs
later, so the first non-`None` output lands on input `long + short`.
## Edge cases
- **Pure trend.** A monotone rising series saturates at `+100`, a falling
one at `100``|momentum|` equals `momentum` (or its negative), so the
ratio is `±1` (`pure_uptrend_saturates_at_plus_100` /
`pure_downtrend_saturates_at_minus_100` pin this).
- **Constant series.** Every momentum is `0`; the `0 / 0` is guarded and
the output is `0.0` (`constant_series_yields_zero` pins this).
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the
smoothing chains are not advanced.
- **Reset.** `tsi.reset()` clears the previous price and all four EMAs.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Tsi};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut tsi = Tsi::new(5, 3)?;
let out = tsi.batch(&prices);
println!("warmup_period = {}", tsi.warmup_period());
println!("last = {:?}", out.last().unwrap());
Ok(())
}
```
Output:
```
warmup_period = 8
last = Some(100.0)
```
A pure ramp has a constant `+1` momentum, so the double-smoothed ratio is
exactly `1` and TSI saturates at `+100`. This matches the
`pure_uptrend_saturates_at_plus_100` test in
`crates/wickra-core/src/indicators/tsi.rs`.
### Python
```python
import numpy as np
import wickra as ta
tsi = ta.TSI() # (long=25, short=13)
prices = np.linspace(100.0, 80.0, 60) # steady downtrend
out = tsi.batch(prices)
print("last =", out[-1])
```
Output:
```
last = -100.0
```
### Node
```javascript
const ta = require('wickra');
const tsi = new ta.TSI(25, 13);
const prices = Array.from({ length: 60 }, (_, i) => 100 + i);
console.log('last:', tsi.batch(prices).at(-1));
```
## Interpretation
`Tsi` is a low-noise momentum oscillator. The standard signals are the
zero-line cross (momentum changing sign), overbought/oversold extremes
near `±25` for the default settings, and a signal-line cross — many
traders overlay an EMA of TSI and trade the crossover. The double
smoothing makes divergences unusually clean compared with raw momentum.
## Common pitfalls
- **Reading it as a `[0, 100]` oscillator.** TSI is centred on zero and
signed; `+25` is "strong up", not "mid-range".
- **Under-budgeting warmup.** Warmup is `long + short` — for the default
`(25, 13)` that is 38 bars.
## References
William Blau, "True Strength Index", *Technical Analysis of Stocks &
Commodities* (1991), and *Momentum, Direction, and Divergence* (1995).
The double-EMA-of-momentum definition here follows Blau's original.
## See also
- [Indicator-Mom.md](../momentum-oscillators/Indicator-Mom.md) — the raw momentum TSI smooths.
- [Indicator-MacdIndicator.md](../trend-directional/Indicator-MacdIndicator.md) — another
EMA-difference momentum oscillator with a signal line.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,178 @@
# UltimateOscillator
> Ultimate Oscillator — Larry Williams' momentum oscillator that blends
> three lookback periods into one bounded `[0, 100]` reading.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum Oscillators |
| Input type | `Candle` (uses `high`, `low`, `close`) |
| Output type | `f64` |
| Output range | `[0, 100]` |
| Default parameters | `(short = 7, mid = 14, long = 28)` (Python) |
| Warmup period | `max(short, mid, long) + 1` |
| Interpretation | Weighted three-timeframe buying pressure; `50` is neutral. |
## Formula
```
true_low_t = min(low_t, close_{t1})
BP_t = close_t true_low_t (buying pressure)
TR_t = max(high_t, close_{t1}) true_low_t (true range)
avg_n = Σ BP over n / Σ TR over n
UO = 100 · (4·avg_short + 2·avg_mid + avg_long) / 7
```
A single-timeframe momentum oscillator can show false divergences when
its lookback does not match the swing being measured. The Ultimate
Oscillator averages buying pressure over *three* windows and weights the
fastest (`4×`) above the medium (`2×`) and slow (`1×`), which damps those
false signals while keeping the response quick.
## Parameters
| Name | Type | Default | Valid range | Description |
|---------|---------|---------------|-------------|-------------|
| `short` | `usize` | `7` (Python) | `>= 1` | Fast lookback (weight `4`). `0` errors with `Error::PeriodZero`. |
| `mid` | `usize` | `14` (Python) | `>= 1` | Medium lookback (weight `2`). |
| `long` | `usize` | `28` (Python) | `>= 1` | Slow lookback (weight `1`). |
The Python binding defaults the trio to `(7, 14, 28)` via
`#[pyo3(signature = (short=7, mid=14, long=28))]`. Node and WASM take all
three explicitly. The `periods` property returns `(short, mid, long)`.
`UltimateOscillator::classic()` is the conventional `(7, 14, 28)`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/ultimate_oscillator.rs`:
```rust
impl Indicator for UltimateOscillator {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
`UltimateOscillator` is a **candle-input** indicator: it reads `high`,
`low` and `close`. In Python the 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 `batch(high, low, close)`.
## Warmup
`warmup_period() == max(short, mid, long) + 1`. The first bar has no
previous close, so the first `BP`/`TR` pair forms on bar 2; the longest
window must then fill, so the first non-`None` output lands on input
`max(short, mid, long) + 1`.
## Edge cases
- **Pure uptrend.** Bars that each close higher have `BP == TR`, so every
ratio is `1` and UO saturates at `100`
(`pure_uptrend_saturates_at_100` pins this).
- **Pure downtrend.** Bars that each close lower have `BP == 0`, so UO is
`0` (`pure_downtrend_saturates_at_0` pins this).
- **Flat market.** Identical bars have zero true range; each window
contributes the neutral ratio `0.5`, so UO reads `50`
(`flat_market_reads_50` pins this).
- **Bounds.** The output is always within `[0, 100]`
(`output_stays_within_0_100` pins this).
- **Candle validation.** `Candle::new` rejects NaN/infinite fields, so
`update` never sees an invalid bar.
- **Reset.** `uo.reset()` clears the previous close, the rolling window
and all six running sums.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, UltimateOscillator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut uo = UltimateOscillator::classic(); // (7, 14, 28)
// 30 flat candles, each closing one tick higher than the last.
let candles: Vec<Candle> = (0..40)
.map(|i| {
let p = 100.0 + f64::from(i);
Candle::new(p, p, p, p, 1.0, i64::from(i)).unwrap()
})
.collect();
let out = uo.batch(&candles);
println!("warmup_period = {}", uo.warmup_period());
println!("last = {:?}", out.last().unwrap());
Ok(())
}
```
Output:
```
warmup_period = 29
last = Some(100.0)
```
Every bar closes higher with `BP == TR`, so UO saturates at `100`. This
matches the `pure_uptrend_saturates_at_100` test in
`crates/wickra-core/src/indicators/ultimate_oscillator.rs`.
### Python
```python
import numpy as np
import wickra as ta
uo = ta.UltimateOscillator() # (7, 14, 28)
high = np.full(40, 100.0)
low = np.full(40, 100.0)
close = np.full(40, 100.0) # perfectly flat market
print(uo.batch(high, low, close)[-1])
```
Output:
```
50.0
```
### Node
```javascript
const ta = require('wickra');
const uo = new ta.UltimateOscillator(7, 14, 28);
const flat = Array.from({ length: 40 }, () => 100);
console.log(uo.batch(flat, flat, flat).at(-1)); // 50
```
## Interpretation
`UltimateOscillator` is read with the usual overbought/oversold lens —
above `70` is stretched, below `30` is washed out — but Larry Williams'
canonical signal is *divergence with confirmation*: price makes a new
extreme while UO does not, then UO breaks the level of the divergence.
The three-timeframe blend makes those divergences more reliable than a
single-period oscillator.
## Common pitfalls
- **Feeding it scalar prices.** It needs `high`/`low`/`close`; it takes a
`Candle`, not an `f64`.
- **Reordering the periods.** The `4 / 2 / 1` weights assume `short` is
the fastest window — keep `short < mid < long`. Any positive periods
are accepted, but mis-ordering them inverts the intended weighting.
## References
Larry Williams, "The Ultimate Oscillator", *Technical Analysis of Stocks
& Commodities* (1985). The buying-pressure / true-range definition and the
`4 / 2 / 1` weighting follow Williams' original.
## See also
- [Indicator-Stochastic.md](../momentum-oscillators/Indicator-Stochastic.md) — single-timeframe
bounded oscillator.
- [Indicator-Rsi.md](../momentum-oscillators/Indicator-Rsi.md) — the canonical momentum oscillator.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,183 @@
# 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 Oscillators |
| 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](../momentum-oscillators/Indicator-Stochastic.md) — the positive-axis
sibling; `%R` and `%K` are linked by `%R = %K 100`.
- [Indicator: Rsi](../momentum-oscillators/Indicator-Rsi.md) — slower bounded oscillator,
better behaved in trending markets.
- [Warmup Periods](../../Warmup-Periods.md) — bare `period` (no off-by-one).