PGO
Pretty Good Oscillator — Mark Johnson's displacement of the close from its
period-bar SMA, normalised by theperiod-bar EMA of the True Range. Roughly "how many ATR-equivalents is the close from its mean?".
Quick reference
| Field | Value |
|---|---|
| Family | Price Oscillators |
| Input type | Candle (uses high, low, close) |
| Output type | f64 |
| Output range | unbounded; in ATR-equivalent units around zero |
| Default parameters | period = 14 |
| Warmup period | period (exact) |
| Interpretation | A +3 cross is Johnson's long entry, a −3 cross his short entry. |
Formula
PGO_t = (close_t − SMA(close, period)_t) / EMA(TR_t, period)The numerator is positive when the close is above its mean of the last period bars, negative when below. The denominator is the EMA-smoothed TrueRange, a volatility scale — so PGO normalises the displacement into ATR-equivalent units, making thresholds like ±3 comparable across instruments.
Parameters
| Name | Type | Default | Constraint | Source |
|---|---|---|---|---|
period | usize | 14 | >= 1 | Pgo::new (pgo.rs:52) |
period == 0 returns [Error::PeriodZero]. Python default comes from #[pyo3(signature = (period=14))]; the Node constructor takes period explicitly. The public class is PGO in both bindings.
Inputs / Outputs
use wickra::{Indicator, Pgo, Candle};
// Pgo: Input = Candle, Output = f64
const _: fn(&mut Pgo, Candle) -> Option<f64> = <Pgo as Indicator>::update;- Python.
update(candle)returnsfloat | None;batch(high, low, close)returns a 1-Dfloat64np.ndarraywithNaNwarmup. - Node.
update(high, low, close)returnsnumber | null;batch(high, low, close)returns anArray<number>withNaNwarmup.
Warmup
warmup_period() returns period. The SMA of close and the EMA of the True Range both reach readiness at exactly period candles, so PGO emits its first value on candle period (index period − 1). Pinned by warmup_emits_first_value_at_period (period 3: candles 1–2 return None, candle 3 emits).
Edge cases
- Flat close. A constant close makes
close == SMA, so the numerator is0and PGO= 0regardless of the (non-zero) TR denominator (testflat_close_yields_zero_numerator). - Rising series. The latest close sits above its SMA, so PGO
> 0(testclose_above_mean_is_positive). - Zero True Range. A window of single-point candles (
high == low == close) drivesEMA(TR)to0; PGO then holds its previous value (and staysNoneif no value has been emitted yet — testzero_tr_holds_value). - Reset.
reset()clears the SMA, the True Range, the EMA and the held value.
Examples
Rust
use wickra::{Candle, Indicator, Pgo};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut pgo = Pgo::new(5)?;
let mut last = None;
for i in 0..20 {
let c = 10.0 + i as f64; // rising close
last = pgo.update(Candle::new(c, c + 0.5, c - 0.5, c, 1.0, i)?);
}
println!("{last:?}"); // Some(positive) — close above its SMA
Ok(())
}Python
import numpy as np
import wickra as ta
pgo = ta.PGO(14)
out = pgo.batch(high, low, close) # 1-D series, NaN for the first 13 rowsNode
const ta = require('wickra');
const pgo = new ta.PGO(14);
const v = pgo.update(101.0, 99.0, 100.5); // null during warmup, else a numberInterpretation
PGO answers "how stretched is price from its own mean, in volatility units?":
- Mean-reversion / breakout thresholds. Johnson's heuristic uses
±3: a cross above+3is a momentum long, a cross below−3a momentum short — treating large normalised displacement as a breakout, not an exhaustion. - Cross-instrument comparability. Because the displacement is divided by an ATR-like scale, the
±3threshold means roughly the same thing on a quiet bond future as on a volatile altcoin.
Common pitfalls
- Reading raw price distance. PGO is normalised by volatility — a reading of
2means "2 ATR-equivalents above the mean", not 2 price units. - Mistaking it for an oscillator with fixed bounds. Despite the name, PGO is unbounded; only the
±3convention gives it working thresholds.
References
- Mark Johnson, "Pretty Good Oscillator", Technical Analysis of Stocks & Commodities, 1995.