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        if period > crate::error::MAX_PERIOD {
57            return Err(Error::InvalidPeriod {
58                message: crate::error::PERIOD_ABOVE_MAX,
59            });
60        }
61        Ok(Self {
62            period,
63            sma: Sma::new(period)?,
64            tr: TrueRange::new(),
65            ema_tr: Ema::new(period)?,
66            current: None,
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74}
75
76impl Indicator for Pgo {
77    type Input = Candle;
78    type Output = f64;
79
80    #[inline]
81    fn update(&mut self, candle: Candle) -> Option<f64> {
82        let mean = self.sma.update(candle.close);
83        // TrueRange always emits (it falls back to high − low without a
84        // previous close), so we can unwrap the inner option safely.
85        let tr = self.tr.update(candle).expect("TrueRange always emits");
86        let ema_tr = self.ema_tr.update(tr);
87        let mean = mean?;
88        let ema_tr = ema_tr?;
89        if ema_tr <= 0.0 {
90            // Pathological window of perfectly flat candles: divisor zero.
91            // Hold the previous value rather than blow up.
92            return self.current;
93        }
94        let value = (candle.close - mean) / ema_tr;
95        self.current = Some(value);
96        Some(value)
97    }
98
99    fn reset(&mut self) {
100        self.sma.reset();
101        self.tr.reset();
102        self.ema_tr.reset();
103        self.current = None;
104    }
105
106    #[inline]
107    fn warmup_period(&self) -> usize {
108        // Both inner state machines reach readiness at exactly `period`
109        // candles, so PGO emits at the same boundary.
110        self.period
111    }
112
113    #[inline]
114    fn is_ready(&self) -> bool {
115        self.current.is_some()
116    }
117
118    #[inline]
119    fn name(&self) -> &'static str {
120        "PGO"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128    use approx::assert_relative_eq;
129
130    fn candle(close: f64, high: f64, low: f64, ts: i64) -> Candle {
131        Candle::new(close, high, low, close, 1.0, ts).unwrap()
132    }
133
134    #[test]
135    fn rejects_zero_period() {
136        assert!(matches!(Pgo::new(0), Err(Error::PeriodZero)));
137    }
138
139    #[test]
140    fn accessors_and_metadata() {
141        let mut p = Pgo::new(14).unwrap();
142        assert_eq!(p.period(), 14);
143        assert_eq!(p.warmup_period(), 14);
144        assert_eq!(p.name(), "PGO");
145        assert!(!p.is_ready());
146        for i in 0..14 {
147            p.update(candle(10.0, 11.0, 9.0, i));
148        }
149        assert!(p.is_ready());
150    }
151
152    #[test]
153    fn flat_close_yields_zero_numerator() {
154        // Constant close -> SMA == close, so numerator is 0 regardless of the
155        // TR-EMA in the denominator (which is non-zero thanks to spread).
156        let mut p = Pgo::new(5).unwrap();
157        let mut out = None;
158        for i in 0..20 {
159            out = p.update(candle(10.0, 11.0, 9.0, i));
160        }
161        let v = out.unwrap();
162        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
163    }
164
165    #[test]
166    fn warmup_emits_first_value_at_period() {
167        let mut p = Pgo::new(3).unwrap();
168        for i in 0..2 {
169            assert_eq!(p.update(candle(10.0, 11.0, 9.0, i)), None);
170        }
171        assert!(p.update(candle(10.0, 11.0, 9.0, 2)).is_some());
172    }
173
174    #[test]
175    fn close_above_mean_is_positive() {
176        // Rising series: latest close sits above its SMA, so PGO > 0.
177        let mut p = Pgo::new(5).unwrap();
178        for i in 0..20 {
179            let c = 10.0 + f64::from(i);
180            p.update(candle(c, c + 0.5, c - 0.5, i64::from(i)));
181        }
182        // Use the last value implicitly.
183        let last = p.update(candle(40.0, 40.5, 39.5, 20)).expect("PGO is warm");
184        assert!(
185            last > 0.0,
186            "PGO on rising series should be positive: {last}"
187        );
188    }
189
190    #[test]
191    fn zero_tr_holds_value() {
192        // Every candle is a single point (high == low == close): TR is zero,
193        // EMA(TR) collapses to zero -> PGO holds its previous value.
194        let mut p = Pgo::new(3).unwrap();
195        p.update(candle(10.0, 10.0, 10.0, 0));
196        p.update(candle(10.0, 10.0, 10.0, 1));
197        let v = p.update(candle(10.0, 10.0, 10.0, 2));
198        // With zero denominator on the first ready step we have no previous
199        // value, so the indicator stays unset.
200        assert!(v.is_none(), "expected hold, got {v:?}");
201    }
202
203    #[test]
204    fn batch_equals_streaming() {
205        let candles: Vec<Candle> = (0..60_i64)
206            .map(|i| {
207                let c = 100.0 + (i as f64 * 0.3).sin() * 8.0;
208                candle(c, c + 1.0, c - 1.0, i)
209            })
210            .collect();
211        let batch = Pgo::new(14).unwrap().batch(&candles);
212        let mut b = Pgo::new(14).unwrap();
213        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
214        assert_eq!(batch, streamed);
215    }
216
217    #[test]
218    fn reset_clears_state() {
219        let mut p = Pgo::new(5).unwrap();
220        for i in 0..20 {
221            p.update(candle(10.0, 11.0, 9.0, i));
222        }
223        assert!(p.is_ready());
224        p.reset();
225        assert!(!p.is_ready());
226        assert_eq!(p.update(candle(10.0, 11.0, 9.0, 0)), None);
227    }
228}