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,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 70–80 % of values fall inside `[−100, +100]`. The implementation
|
||||
exposes the factor through `Cci::with_factor(period, factor)` if you want
|
||||
to retune it for an asset with very different volatility characteristics.
|
||||
|
||||
When `MAD == 0` (a perfectly flat window), the implementation returns `0`
|
||||
rather than dividing by zero.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Name | Type | Default (Python) | Valid range | Description |
|
||||
|------|------|------------------|-------------|-------------|
|
||||
| `period` | `usize` | `20` | `>= 1` | Rolling window length for both the SMA of typical price and the MAD. |
|
||||
| `factor` | `f64` | `0.015` (`Cci::new`) | `> 0`, finite | Lambert's scaling constant; configurable via `Cci::with_factor`. |
|
||||
|
||||
`Cci::new(0)` returns `Error::PeriodZero`. `Cci::with_factor(_, factor)`
|
||||
returns `Error::NonPositiveMultiplier` when `factor <= 0` or non-finite.
|
||||
|
||||
## Inputs / Outputs
|
||||
|
||||
From `impl Indicator for Cci`:
|
||||
|
||||
```rust
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
fn update(&mut self, candle: Candle) -> Option<f64>;
|
||||
```
|
||||
|
||||
Python's `CCI.batch(high, low, close)` returns a 1-D `float64` `np.ndarray`
|
||||
with `NaN` during warmup. Node's `CCI.batch(high, low, close)` returns a
|
||||
flat `number[]` (also `NaN` during warmup); the Node binding does not
|
||||
expose a streaming `update()` (`bindings/node/index.d.ts` lists only
|
||||
`constructor` and `batch`).
|
||||
|
||||
## Warmup
|
||||
|
||||
`warmup_period()` returns exactly `period`. CCI does not consume diffs —
|
||||
it only needs `period` typical-price samples to populate its rolling
|
||||
window before it can compute an SMA and MAD. In streaming terms, calls
|
||||
`1..period` return `None`; the `period`-th call returns the first value.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Flat input.** Every `TP` is the SMA, so `MAD == 0` and the
|
||||
implementation returns `0.0` (test `flat_candles_yield_zero`). This
|
||||
avoids the divide-by-zero that would otherwise produce `NaN` /
|
||||
`±∞`.
|
||||
- **Custom factor.** `Cci::with_factor(period, factor)` lets you replace
|
||||
Lambert's `0.015`. Picking a smaller factor widens the typical range
|
||||
of CCI values; picking a larger one compresses them.
|
||||
- **Reset.** `reset()` clears the rolling window and the running sum,
|
||||
returning the indicator to the freshly-constructed state.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Candle, Cci, Indicator};
|
||||
|
||||
let candles: Vec<Candle> = (0..25)
|
||||
.map(|i| {
|
||||
let m = 50.0 + i as f64;
|
||||
Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut cci = Cci::new(20)?;
|
||||
let out = cci.batch(&candles);
|
||||
println!("row 19 = {}", out[19].unwrap());
|
||||
println!("row 24 = {}", out[24].unwrap());
|
||||
# Ok::<(), wickra::Error>(())
|
||||
```
|
||||
|
||||
Verified output:
|
||||
|
||||
```
|
||||
row 19 = 126.66666666666667
|
||||
row 24 = 126.66666666666667
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
i = np.arange(25, dtype=float)
|
||||
m = 50.0 + i
|
||||
high = m + 1.0
|
||||
low = m - 1.0
|
||||
close = m
|
||||
cci = ta.CCI(20)
|
||||
out = cci.batch(high, low, close)
|
||||
print('row 19:', out[19])
|
||||
print('row 24:', out[24])
|
||||
```
|
||||
|
||||
Verified output:
|
||||
|
||||
```
|
||||
row 19: 126.66666666666667
|
||||
row 24: 126.66666666666667
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const wickra = require('wickra');
|
||||
|
||||
const n = 25;
|
||||
const high = [], low = [], close = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = 50 + i;
|
||||
high.push(m + 1);
|
||||
low.push(m - 1);
|
||||
close.push(m);
|
||||
}
|
||||
const cci = new wickra.CCI(20);
|
||||
const out = cci.batch(high, low, close);
|
||||
console.log('row 19:', out[19]);
|
||||
console.log('row 24:', out[24]);
|
||||
```
|
||||
|
||||
Verified output:
|
||||
|
||||
```
|
||||
row 19: 126.66666666666667
|
||||
row 24: 126.66666666666667
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
- **±100 threshold.** Lambert's published convention is to treat values
|
||||
above `+100` as overbought and below `−100` as oversold. The choice
|
||||
of `0.015` for the divisor is what makes the threshold meaningful;
|
||||
changing the factor changes the threshold.
|
||||
- **Zero-line cross.** `CCI` crossing zero says the typical price has
|
||||
moved through its `period`-bar mean — sometimes used as a
|
||||
trend-direction filter.
|
||||
- **Divergence.** As with RSI/Stochastic, a price making a new high
|
||||
while CCI makes a lower high is a classic bearish divergence.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **CCI is unbounded.** Unlike RSI or Stochastic, CCI can spike well
|
||||
outside `±100` in volatile markets. Threshold-based rules should be
|
||||
paired with a maximum-absolute-value guard, or you will mis-classify
|
||||
legitimate breakouts as "extreme overbought".
|
||||
- **The 0.015 factor is empirical, not derived.** It was chosen by
|
||||
Lambert in 1980 for commodity futures markets. Modern equities and
|
||||
crypto have wider distributions; if your `|CCI|` distribution sits
|
||||
almost entirely outside `±100`, retune via `Cci::with_factor` rather
|
||||
than rewriting downstream thresholds.
|
||||
|
||||
## References
|
||||
|
||||
- Donald Lambert, "Commodity Channel Index: Tools for Trading Cyclical
|
||||
Trends", *Commodities Magazine*, October 1980 — the original
|
||||
publication, including the empirical choice of `0.015`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator: Rsi](../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_{t−1}, 0)
|
||||
loss_t = max(price_{t−1} − 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_{t−period}`, 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_{t−period}
|
||||
```
|
||||
|
||||
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_{t−1} − 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 H–L range). |
|
||||
| `d` | `SMA(d_period)` of the `%K` series — the slower "signal" line. |
|
||||
|
||||
Python's `Stochastic.batch(high, low, close)` returns a `(n, 2)` array
|
||||
with columns `[k, d]`; warmup rows are `[NaN, NaN]`.
|
||||
|
||||
Node's `Stochastic.batch(high, low, close)` returns a flat `number[]`
|
||||
of length `n * 2`, interleaved as `[k_0, d_0, k_1, d_1, …]`. There is
|
||||
no streaming `update()` on the Node binding — only `batch` is exposed.
|
||||
|
||||
## Warmup
|
||||
|
||||
`warmup_period()` returns `k_period + d_period − 1`. The `%K` series itself
|
||||
becomes available at input `k_period`; the `%D` SMA then needs `d_period`
|
||||
of those `%K` values to seed, producing its first output at input
|
||||
`k_period + d_period − 1`. For the classic `(14, 3)` configuration this is
|
||||
`16` — verified above.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Flat range (`HH == LL`).** The implementation returns `%K = 50` by
|
||||
convention (mirroring RSI's flat-input behaviour). The unit test
|
||||
`flat_range_yields_k_50` pins this; with a constant input both `%K` and
|
||||
`%D` collapse to `50`.
|
||||
- **Close at the window high.** `%K = 100` exactly; close at the window
|
||||
low gives `%K = 0` exactly (tests `close_at_high_yields_k_100` and
|
||||
`close_at_low_yields_k_0`).
|
||||
- **Reset.** `reset()` clears the candle buffer, both monotonic deques,
|
||||
the SMA, and `last_k` — the indicator returns to a freshly-constructed
|
||||
state.
|
||||
|
||||
## Examples
|
||||
|
||||
### Rust
|
||||
|
||||
```rust
|
||||
use wickra::{BatchExt, Candle, Indicator, Stochastic};
|
||||
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| {
|
||||
let m = 10.0 + (i as f64 * 0.5).sin() * 2.0;
|
||||
Candle::new(m, m + 1.0, m - 1.0, m, 1.0, 0).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut s = Stochastic::new(14, 3)?;
|
||||
let out = s.batch(&candles);
|
||||
let v = out[15].unwrap();
|
||||
println!("row 15 k={} d={}", v.k, v.d);
|
||||
let v = out[19].unwrap();
|
||||
println!("row 19 k={} d={}", v.k, v.d);
|
||||
# Ok::<(), wickra::Error>(())
|
||||
```
|
||||
|
||||
Verified output:
|
||||
|
||||
```
|
||||
row 15 k=81.19360374383255 d=69.94559370965067
|
||||
row 19 k=47.26766986190959 d=62.55762656278284
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import wickra as ta
|
||||
|
||||
n = 20
|
||||
i = np.arange(n, dtype=float)
|
||||
m = 10.0 + np.sin(i * 0.5) * 2.0
|
||||
high = m + 1.0
|
||||
low = m - 1.0
|
||||
close = m
|
||||
stoch = ta.Stochastic(14, 3)
|
||||
out = stoch.batch(high, low, close)
|
||||
print('shape :', out.shape)
|
||||
print('warmup:', stoch.warmup_period())
|
||||
print('row 15:', out[15])
|
||||
print('row 19:', out[19])
|
||||
```
|
||||
|
||||
Verified output:
|
||||
|
||||
```
|
||||
shape : (20, 2)
|
||||
warmup: 16
|
||||
row 15: [81.19360374 69.94559371]
|
||||
row 19: [47.26766986 62.55762656]
|
||||
```
|
||||
|
||||
### Node
|
||||
|
||||
```javascript
|
||||
const wickra = require('wickra');
|
||||
|
||||
const n = 20;
|
||||
const high = [], low = [], close = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = 10.0 + Math.sin(i * 0.5) * 2.0;
|
||||
high.push(m + 1.0);
|
||||
low.push(m - 1.0);
|
||||
close.push(m);
|
||||
}
|
||||
const s = new wickra.Stochastic(14, 3);
|
||||
const out = s.batch(high, low, close);
|
||||
console.log('len :', out.length);
|
||||
console.log('row 15 :', { k: out[15 * 2], d: out[15 * 2 + 1] });
|
||||
console.log('row 19 :', { k: out[19 * 2], d: out[19 * 2 + 1] });
|
||||
```
|
||||
|
||||
Verified output:
|
||||
|
||||
```
|
||||
len : 40
|
||||
row 15 : { k: 81.19360374383255, d: 69.94559370965067 }
|
||||
row 19 : { k: 47.26766986190959, d: 62.55762656278284 }
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
- **Overbought / oversold zones.** The canonical Lane thresholds are
|
||||
`80` and `20`. Crossings back from outside these bands are typically
|
||||
used as reversal-confirmation signals, not entries on their own.
|
||||
- **`%K` / `%D` crossover.** `%K` crossing above `%D` from below is a
|
||||
short-horizon bullish signal; the mirror cross is bearish.
|
||||
- **Divergence.** A price making a new high but `%K` failing to confirm
|
||||
is a classic bearish divergence — same logic as RSI divergence but on
|
||||
a faster, range-based oscillator.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **`%K` on a flat candle window is `50`, not undefined.** During a
|
||||
quiet drift where `HH == LL`, the convention used here is `50.0` and
|
||||
`%D` therefore also converges to `50.0`. Do not interpret a sequence
|
||||
of `50`s as a real oversold/overbought cycle — it is the silent-market
|
||||
fallback path.
|
||||
- **Wickra exposes only the fast variant.** "Slow stochastic" is `%K =
|
||||
SMA(raw_%K, slow_k)` with `%D = SMA(%K, d_period)` on top. The
|
||||
built-in `Stochastic` skips the first SMA; to reproduce the slow
|
||||
variant, drive the raw `%K` (taken from `stoch.update(candle).k`)
|
||||
through your own `Sma`.
|
||||
|
||||
## References
|
||||
|
||||
- George C. Lane, *Investment Educators* seminars and articles
|
||||
(late 1950s, popularised through the 1980s) — the original
|
||||
formulation of `%K` and `%D` as a fast oscillator.
|
||||
|
||||
## See also
|
||||
|
||||
- [Indicator: Rsi](../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_{t−1}
|
||||
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_{t−1})
|
||||
BP_t = close_t − true_low_t (buying pressure)
|
||||
TR_t = max(high_t, close_{t−1}) − 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).
|
||||
Reference in New Issue
Block a user