F5: add PPO, DPO and Coppock Curve price oscillators

Completes the F5 family (Price oscillators) end to end:

- Rust core: ppo.rs (Percentage Price Oscillator — MACD as a percentage
  of the slow EMA), dpo.rs (Detrended Price Oscillator — shifted price
  minus its SMA), coppock.rs (Coppock Curve — WMA of two summed ROCs).
  Each with a full Indicator impl, runnable doctest and reference /
  constant-series / warmup / reset / batch==streaming / non-finite tests.
- Python: PyPpo / PyDpo / PyCoppock PyO3 classes + module registration
  + .pyi stubs (defaults PPO=(12,26), DPO=20, Coppock=(14,11,10)).
- Node: DpoNode via the scalar macro, explicit PpoNode and CoppockNode;
  index.d.ts and index.js updated.
- WASM: WasmDpo / WasmPpo / WasmCoppock via the scalar macro.
- Wiki: Indicator-Ppo/Dpo/Coppock.md plus rows in Indicators-Overview.md
  and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 300 core tests,
25 data tests and 42 doctests green.
This commit is contained in:
kingchenc
2026-05-22 18:09:10 +02:00
parent e24e7726ce
commit 54148cad5b
15 changed files with 1385 additions and 4 deletions
+3
View File
@@ -104,6 +104,9 @@ Rust / Python / Node examples. They are grouped by family, mirroring the
- [Indicator-Pmo.md](indicators/momentum/Indicator-Pmo.md)
- [Indicator-StochRsi.md](indicators/momentum/Indicator-StochRsi.md)
- [Indicator-UltimateOscillator.md](indicators/momentum/Indicator-UltimateOscillator.md)
- [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md)
- [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md)
- [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md)
**Volatility** — envelope width and per-bar dispersion measures.
+4 -1
View File
@@ -1,6 +1,6 @@
# Indicators Overview
Wickra ships 36 indicators, organised in source under the four classical
Wickra ships 39 indicators, organised in source under the four classical
families — trend, momentum, volatility, volume — that map directly to the
directory structure of `crates/wickra-core/src/indicators/`. The same family
labels are used here, plus a second-level grouping that reflects how the
@@ -103,6 +103,9 @@ Centered on zero or driven by raw price differences; no fixed cap.
| `Cmo` | Chande Momentum Oscillator; `100·(Σgain Σloss)/(Σgain + Σloss)` over `period` changes. | `f64` | `f64` | `[100, 100]` | `period = 14` (Python) | `period + 1` | [Indicator-Cmo.md](indicators/momentum/Indicator-Cmo.md) |
| `Tsi` | True Strength Index; ratio of double-EMA-smoothed momentum to its absolute value. | `f64` | `f64` | ≈ `[100, 100]` around zero | `(long=25, short=13)` (Python) | `long + short` | [Indicator-Tsi.md](indicators/momentum/Indicator-Tsi.md) |
| `Pmo` | DecisionPoint Price Momentum Oscillator; doubly-smoothed rate of change. | `f64` | `f64` | unbounded around zero | `(smoothing1=35, smoothing2=20)` (Python) | `2` | [Indicator-Pmo.md](indicators/momentum/Indicator-Pmo.md) |
| `Ppo` | Percentage Price Oscillator; `100·(EMA_fast EMA_slow)/EMA_slow`. | `f64` | `f64` | unbounded around zero (percent) | `(fast=12, slow=26)` (Python) | `slow` | [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md) |
| `Dpo` | Detrended Price Oscillator; `price[t period/2 1] SMA(period)`. | `f64` | `f64` | unbounded around zero | `period = 20` (Python) | `max(period, period/2 + 2)` | [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md) |
| `Coppock` | Coppock Curve; `WMA(ROC(long) + ROC(short), wma_period)`. | `f64` | `f64` | unbounded around zero | `(roc_long=14, roc_short=11, wma_period=10)` (Python) | `max(roc_long, roc_short) + wma_period` | [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md) |
### Directional
@@ -0,0 +1,154 @@
# Coppock
> Coppock Curve — a long-horizon momentum indicator: a weighted moving
> average of two rates of change, designed to flag major bottoms.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | Unbounded oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded around zero |
| Default parameters | `(roc_long = 14, roc_short = 11, wma_period = 10)` (Python) |
| Warmup period | `max(roc_long, roc_short) + wma_period` |
| Interpretation | Long-term momentum; an upturn from below zero is the buy signal. |
## Formula
```
Coppock = WMA( ROC(roc_long) + ROC(roc_short), wma_period )
```
Edwin Coppock built this in 1962 as a long-horizon buy signal for stock
indices. The two rates of change blend a slightly longer and a slightly
shorter momentum horizon; the [`Wma`](../trend/Indicator-Wma.md) smooths
their sum. On a **monthly** chart with the conventional
`(14, 11, 10)` settings, the curve turning *up from below zero* has
historically marked the start of a new bull phase.
## Parameters
| Name | Type | Default | Valid range | Description |
|--------------|---------|---------------|-------------|-------------|
| `roc_long` | `usize` | `14` (Python) | `>= 1` | Longer ROC period. `0` errors with `Error::PeriodZero`. |
| `roc_short` | `usize` | `11` (Python) | `>= 1` | Shorter ROC period. |
| `wma_period` | `usize` | `10` (Python) | `>= 1` | WMA smoothing length. |
The Python binding defaults the trio to `(14, 11, 10)`. The `periods`
property returns `(roc_long, roc_short, wma_period)`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/coppock.rs`:
```rust
impl Indicator for Coppock {
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
`warmup_period() == max(roc_long, roc_short) + wma_period`. Each ROC emits
its first value at input `roc_period + 1`; the longer ROC is the last to
become ready, and the WMA then needs `wma_period` of the summed ROC
values — so the first non-`None` output lands on input
`max(roc_long, roc_short) + wma_period`.
## Edge cases
- **Constant series.** Both ROCs are `0` on a flat series, so the WMA of
zeros — and the curve — is `0` (`constant_series_yields_zero` pins
this).
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; no
component is advanced.
- **Reset.** `coppock.reset()` clears both ROCs and the WMA.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Coppock};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut coppock = Coppock::new(14, 11, 10)?;
let prices: Vec<f64> = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
let out = coppock.batch(&prices);
println!("warmup_period = {}", coppock.warmup_period());
println!("last > 0: {}", out.last().unwrap().unwrap() > 0.0);
Ok(())
}
```
Output:
```
warmup_period = 24
last > 0: true
```
A steady uptrend keeps both ROCs positive, so the Coppock Curve stays
above zero.
### Python
```python
import numpy as np
import wickra as ta
coppock = ta.Coppock() # (roc_long=14, roc_short=11, wma_period=10)
prices = np.full(60, 100.0) # flat series
print(coppock.batch(prices)[-1]) # ROCs are 0 -> 0
```
Output:
```
0.0
```
### Node
```javascript
const ta = require('wickra');
const coppock = new ta.Coppock(14, 11, 10);
const prices = Array.from({ length: 120 }, (_, i) => 100 * 1.01 ** i);
console.log('warmupPeriod:', coppock.warmupPeriod());
```
## Interpretation
`Coppock` is a long-horizon signal, traditionally read on **monthly**
data. The canonical rule is a single one: when the curve has been below
zero and turns up, that is a long-term buy. It was not designed to give
sell signals — Coppock left exits to other tools. On faster timeframes it
behaves as a smoothed momentum oscillator, but its statistical edge is
specifically the monthly bottom call.
## Common pitfalls
- **Using it for sell signals.** The Coppock Curve is a buy-only
indicator by design; pair it with a separate exit rule.
- **Applying it intraday and expecting the historical edge.** The
documented behaviour is for monthly index charts.
## References
E. S. Coppock, "Practical Relative Strength Charting", *Barron's* (1962).
The `WMA(ROC(14) + ROC(11), 10)` construction here is Coppock's original.
## See also
- [Indicator-Roc.md](Indicator-Roc.md) — the rate-of-change building block.
- [Indicator-Wma.md](../trend/Indicator-Wma.md) — the smoothing average.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,162 @@
# DPO
> Detrended Price Oscillator — removes the trend from price by comparing a
> shifted past price to the moving average, exposing the underlying cycle.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | Unbounded oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded around zero (price-difference scale) |
| Default parameters | `period = 20` (Python) |
| Warmup period | `max(period, period / 2 + 2)` |
| Interpretation | Detrended price; peak-to-peak spacing reveals the cycle length. |
## Formula
```
shift = period / 2 + 1
DPO_t = price_{t shift} SMA(period)_t
```
A normal oscillator compares price to a *current* average and therefore
still carries the trend. DPO instead subtracts the average from a price
taken `period / 2 + 1` bars **back** — roughly half a cycle. The dominant
trend cancels, and what is left swings around zero with the same period
as the price's shorter cycles, so the distance between DPO peaks reads off
the cycle length directly.
DPO is **not** a momentum or signal indicator: by construction it is
shifted into the past and is not meant to track the latest bar.
## Parameters
| Name | Type | Default | Valid range | Description |
|----------|---------|---------------|-------------|-------------|
| `period` | `usize` | `20` (Python) | `>= 1` | SMA length; also sets the look-back `shift = period / 2 + 1`. `0` errors with `Error::PeriodZero`. |
The Python binding defaults `period` to `20`. The derived `shift` is
exposed as a read-only property.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/dpo.rs`:
```rust
impl Indicator for Dpo {
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
`warmup_period() == max(period, period / 2 + 2)`. The output needs both a
full `period`-bar SMA window and a price `shift` bars back; the indicator
becomes ready once the rolling window holds enough bars for both. For the
usual `period >= 4` this simplifies to `period`.
## Edge cases
- **Constant series.** On a flat series the shifted price equals the SMA,
so DPO is `0` (`constant_series_yields_zero` pins this).
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the
window is not advanced.
- **Reset.** `dpo.reset()` clears the window and the rolling sum.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Dpo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut dpo = Dpo::new(4)?;
let out: Vec<Option<f64>> = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
println!("{:?}", out);
println!("shift = {}, warmup_period = {}", dpo.shift(), dpo.warmup_period());
Ok(())
}
```
Output:
```
[None, None, None, Some(-1.5), Some(-1.5), Some(-1.5)]
shift = 3, warmup_period = 4
```
`DPO(4)` has `shift = 3`. At input 4 the SMA of `[1,2,3,4]` is `2.5` and
the price 3 bars back is `1`, giving `1 2.5 = 1.5`. On a pure ramp the
detrended value is constant. This matches the `reference_values` test in
`crates/wickra-core/src/indicators/dpo.rs`.
### Python
```python
import numpy as np
import wickra as ta
dpo = ta.DPO(4)
print(dpo.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])))
```
Output:
```
[ nan nan nan -1.5 -1.5 -1.5]
```
### Node
```javascript
const ta = require('wickra');
const dpo = new ta.DPO(4);
console.log(dpo.batch([1, 2, 3, 4, 5, 6]));
```
Output:
```
[ NaN, NaN, NaN, -1.5, -1.5, -1.5 ]
```
## Interpretation
`Dpo` is a cycle-measurement tool, not a trading trigger. Read it for the
*spacing* of its peaks and troughs: regular spacing reveals the dominant
cycle length, which you can then feed back into the periods of other
indicators. Crossing zero is not a signal — because the series is shifted
into the past, the latest DPO value does not correspond to the latest bar.
## Common pitfalls
- **Trading the zero cross.** DPO is detrended *and* time-shifted; its
latest value is historical. Use it to size cycles, not to time entries.
- **Reading it as momentum.** It is a detrended price, not a rate of
change — see [`Roc`](Indicator-Roc.md) or [`Mom`](Indicator-Mom.md) for
momentum.
## References
The Detrended Price Oscillator is a standard cycle-analysis study; the
`period / 2 + 1` look-back shift used here matches the common definition
(StockCharts, TA-Lib-compatible implementations).
## See also
- [Indicator-Sma.md](../trend/Indicator-Sma.md) — the moving average DPO
detrends against.
- [Indicator-Roc.md](Indicator-Roc.md) — momentum, the indicator DPO is
often confused with.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,155 @@
# PPO
> Percentage Price Oscillator — MACD expressed as a percentage of the slow
> EMA, so readings are comparable across instruments.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Momentum |
| Sub-category | Unbounded oscillators |
| Input type | `f64` (single close) |
| Output type | `f64` |
| Output range | unbounded around zero (percent) |
| Default parameters | `(fast = 12, slow = 26)` (Python) |
| Warmup period | `slow` |
| Interpretation | Percentage gap between a fast and slow EMA; zero-line crosses are signals. |
## Formula
```
PPO = 100 · (EMA_fast EMA_slow) / EMA_slow
```
PPO is [`MacdIndicator`](Indicator-MacdIndicator.md) divided by the slow
EMA. That single change makes it **scale-free**: a `PPO` of `1.5` always
means "the fast EMA is 1.5 % above the slow EMA", whether the instrument
trades at $5 or $5000 — so PPO values can be compared across assets and
across time, which raw MACD values cannot. The classic PPO **signal
line** is a 9-period EMA of this PPO line; compose it with
[`Chain`](../Indicator-Chaining.md) and an `Ema(9)`.
## Parameters
| Name | Type | Default | Valid range | Description |
|--------|---------|---------------|------------------|-------------|
| `fast` | `usize` | `12` (Python) | `>= 1`, `< slow` | Fast EMA period. |
| `slow` | `usize` | `26` (Python) | `> fast` | Slow EMA period. |
`fast` must be strictly less than `slow` — otherwise `new` returns
`Error::InvalidPeriod`. A zero period returns `Error::PeriodZero`. The
Python binding defaults the pair to `(12, 26)`; the `periods` property
returns `(fast, slow)`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/ppo.rs`:
```rust
impl Indicator for Ppo {
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
`Ppo::new(fast, slow).warmup_period() == slow`. Both EMAs are SMA-seeded;
the slow EMA is the last to seed, at input `slow`, which is also when PPO
emits its first value.
## Edge cases
- **Constant series.** Both EMAs converge to the constant, so their gap —
and PPO — is `0` (`constant_series_yields_zero` pins this).
- **Zero slow EMA.** A `0.0` slow EMA would divide by zero; PPO reports
`0.0` for that bar instead.
- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the
EMAs are not advanced.
- **Reset.** `ppo.reset()` clears both EMAs and the cached value.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, Ppo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut ppo = Ppo::new(12, 26)?;
let prices: Vec<f64> = (1..=80).map(f64::from).collect();
let out = ppo.batch(&prices);
println!("warmup_period = {}", ppo.warmup_period());
println!("last > 0: {}", out.last().unwrap().unwrap() > 0.0);
Ok(())
}
```
Output:
```
warmup_period = 26
last > 0: true
```
In a rising series the fast EMA leads the slow EMA, so PPO is positive.
### Python
```python
import numpy as np
import wickra as ta
ppo = ta.PPO() # (fast=12, slow=26)
prices = np.full(60, 100.0) # flat series
print(ppo.batch(prices)[-1]) # both EMAs equal -> 0
```
Output:
```
0.0
```
### Node
```javascript
const ta = require('wickra');
const ppo = new ta.PPO(12, 26);
const prices = Array.from({ length: 80 }, (_, i) => 100 + i);
console.log('warmupPeriod:', ppo.warmupPeriod());
```
## Interpretation
`Ppo` is read exactly like MACD: the zero-line cross (fast EMA crossing
the slow EMA), the signal-line cross (PPO crossing its own 9-EMA), and
histogram-style divergence. Its advantage over MACD is comparability — a
PPO scan across a watchlist ranks instruments by *relative* trend
strength, which a MACD scan cannot do because MACD is in each
instrument's own price units.
## Common pitfalls
- **Expecting a bundled signal line.** `Ppo` here is the single PPO line;
add `Ema(9)` via `Chain` for the signal line and histogram.
- **`fast >= slow`.** The constructor rejects it — the fast EMA must be
the faster one.
## References
Gerald Appel's MACD, re-expressed as a percentage. The implementation
follows the standard PPO definition and matches TA-Lib's `PPO`.
## See also
- [Indicator-MacdIndicator.md](Indicator-MacdIndicator.md) — the price-unit
original, with a bundled signal line and histogram.
- [Indicator-Ema.md](../trend/Indicator-Ema.md) — the underlying average.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.