Skip to main content

wickra_core/indicators/
pgo.rs

1//! Pretty Good Oscillator (PGO).
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::sma::Sma;
6use crate::indicators::true_range::TrueRange;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Mark Johnson's Pretty Good Oscillator — displacement of the close from its
11/// `period`-bar `SMA`, normalised by the `period`-bar `EMA` of the True Range.
12///
13/// ```text
14/// PGO_t = (close_t − SMA(close, period)_t) / EMA(TR_t, period)
15/// ```
16///
17/// The numerator is positive when the close is above its mean of the last
18/// `period` bars and negative when below. The denominator is the EMA-smoothed
19/// volatility scale, so PGO is roughly "how many ATR-equivalents is the close
20/// away from its mean?". Johnson's heuristic: cross above `+3` is a long entry,
21/// below `−3` a short entry.
22///
23/// The first output lands once both inner indicators have warmed up — for the
24/// shared `period` parameter, that is exactly `period` candles in.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Candle, Indicator, Pgo};
30///
31/// let mut pgo = Pgo::new(14).unwrap();
32/// let mut last = None;
33/// for i in 0..40 {
34///     let p = 100.0 + f64::from(i);
35///     let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap();
36///     last = pgo.update(candle);
37/// }
38/// assert!(last.is_some());
39/// ```
40#[derive(Debug, Clone)]
41pub struct Pgo {
42    period: usize,
43    sma: Sma,
44    tr: TrueRange,
45    ema_tr: Ema,
46    current: Option<f64>,
47}
48
49impl Pgo {
50    /// # Errors
51    /// Returns [`Error::PeriodZero`] if `period == 0`.
52    pub fn new(period: usize) -> Result<Self> {
53        if period == 0 {
54            return Err(Error::PeriodZero);
55        }
56        Ok(Self {
57            period,
58            sma: Sma::new(period)?,
59            tr: TrueRange::new(),
60            ema_tr: Ema::new(period)?,
61            current: None,
62        })
63    }
64
65    /// Configured period.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69}
70
71impl Indicator for Pgo {
72    type Input = Candle;
73    type Output = f64;
74
75    fn update(&mut self, candle: Candle) -> Option<f64> {
76        let mean = self.sma.update(candle.close);
77        // TrueRange always emits (it falls back to high − low without a
78        // previous close), so we can unwrap the inner option safely.
79        let tr = self.tr.update(candle).expect("TrueRange always emits");
80        let ema_tr = self.ema_tr.update(tr);
81        let mean = mean?;
82        let ema_tr = ema_tr?;
83        if ema_tr <= 0.0 {
84            // Pathological window of perfectly flat candles: divisor zero.
85            // Hold the previous value rather than blow up.
86            return self.current;
87        }
88        let value = (candle.close - mean) / ema_tr;
89        self.current = Some(value);
90        Some(value)
91    }
92
93    fn reset(&mut self) {
94        self.sma.reset();
95        self.tr.reset();
96        self.ema_tr.reset();
97        self.current = None;
98    }
99
100    fn warmup_period(&self) -> usize {
101        // Both inner state machines reach readiness at exactly `period`
102        // candles, so PGO emits at the same boundary.
103        self.period
104    }
105
106    fn is_ready(&self) -> bool {
107        self.current.is_some()
108    }
109
110    fn name(&self) -> &'static str {
111        "PGO"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    fn candle(close: f64, high: f64, low: f64, ts: i64) -> Candle {
122        Candle::new(close, high, low, close, 1.0, ts).unwrap()
123    }
124
125    #[test]
126    fn rejects_zero_period() {
127        assert!(matches!(Pgo::new(0), Err(Error::PeriodZero)));
128    }
129
130    #[test]
131    fn accessors_and_metadata() {
132        let mut p = Pgo::new(14).unwrap();
133        assert_eq!(p.period(), 14);
134        assert_eq!(p.warmup_period(), 14);
135        assert_eq!(p.name(), "PGO");
136        assert!(!p.is_ready());
137        for i in 0..14 {
138            p.update(candle(10.0, 11.0, 9.0, i));
139        }
140        assert!(p.is_ready());
141    }
142
143    #[test]
144    fn flat_close_yields_zero_numerator() {
145        // Constant close -> SMA == close, so numerator is 0 regardless of the
146        // TR-EMA in the denominator (which is non-zero thanks to spread).
147        let mut p = Pgo::new(5).unwrap();
148        let mut out = None;
149        for i in 0..20 {
150            out = p.update(candle(10.0, 11.0, 9.0, i));
151        }
152        let v = out.unwrap();
153        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
154    }
155
156    #[test]
157    fn warmup_emits_first_value_at_period() {
158        let mut p = Pgo::new(3).unwrap();
159        for i in 0..2 {
160            assert_eq!(p.update(candle(10.0, 11.0, 9.0, i)), None);
161        }
162        assert!(p.update(candle(10.0, 11.0, 9.0, 2)).is_some());
163    }
164
165    #[test]
166    fn close_above_mean_is_positive() {
167        // Rising series: latest close sits above its SMA, so PGO > 0.
168        let mut p = Pgo::new(5).unwrap();
169        for i in 0..20 {
170            let c = 10.0 + f64::from(i);
171            p.update(candle(c, c + 0.5, c - 0.5, i64::from(i)));
172        }
173        // Use the last value implicitly.
174        let last = p.update(candle(40.0, 40.5, 39.5, 20)).expect("PGO is warm");
175        assert!(
176            last > 0.0,
177            "PGO on rising series should be positive: {last}"
178        );
179    }
180
181    #[test]
182    fn zero_tr_holds_value() {
183        // Every candle is a single point (high == low == close): TR is zero,
184        // EMA(TR) collapses to zero -> PGO holds its previous value.
185        let mut p = Pgo::new(3).unwrap();
186        p.update(candle(10.0, 10.0, 10.0, 0));
187        p.update(candle(10.0, 10.0, 10.0, 1));
188        let v = p.update(candle(10.0, 10.0, 10.0, 2));
189        // With zero denominator on the first ready step we have no previous
190        // value, so the indicator stays unset.
191        assert!(v.is_none(), "expected hold, got {v:?}");
192    }
193
194    #[test]
195    fn batch_equals_streaming() {
196        let candles: Vec<Candle> = (0..60_i64)
197            .map(|i| {
198                let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
199                candle(c, c + 1.0, c - 1.0, i)
200            })
201            .collect();
202        let batch = Pgo::new(14).unwrap().batch(&candles);
203        let mut b = Pgo::new(14).unwrap();
204        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
205        assert_eq!(batch, streamed);
206    }
207
208    #[test]
209    fn reset_clears_state() {
210        let mut p = Pgo::new(5).unwrap();
211        for i in 0..20 {
212            p.update(candle(10.0, 11.0, 9.0, i));
213        }
214        assert!(p.is_ready());
215        p.reset();
216        assert!(!p.is_ready());
217        assert_eq!(p.update(candle(10.0, 11.0, 9.0, 0)), None);
218    }
219}