Skip to main content

kestrel_chartkit/indicator/
pmo.rs

1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::Ema;
6use super::{Indicator, IndicatorOutput};
7
8/// An exponential average with the smoothing constant stated directly.
9///
10/// The crate's [`Ema`] takes a period and derives `alpha = 2/(len + 1)`. This one takes
11/// `alpha = 2/len`, which is what the Price Momentum Oscillator is defined with. The two agree
12/// only if the period is shifted by one, and silently doing that shift would leave a `length = 35`
13/// in a caller's configuration meaning something else than it says.
14///
15/// Seeded with the first sample, like [`Ema`].
16#[derive(Debug, Clone, Copy)]
17struct DirectAlphaEma {
18    alpha: f64,
19    state: Option<f64>,
20}
21
22impl DirectAlphaEma {
23    fn new(length: usize) -> Self {
24        Self {
25            alpha: 2.0 / length.max(1) as f64,
26            state: None,
27        }
28    }
29
30    fn update(&mut self, value: f64) -> f64 {
31        let next = match self.state {
32            None => value,
33            Some(previous) => self.alpha * value + (1.0 - self.alpha) * previous,
34        };
35        self.state = Some(next);
36        next
37    }
38
39    fn reset(&mut self) {
40        self.state = None;
41    }
42}
43
44/// Price Momentum Oscillator: the one-bar percentage return, smoothed twice and scaled.
45///
46/// ```text
47/// roc    = 100 * (close_t / close_{t-1} - 1)
48/// stage1 = ema(roc,          alpha = 2/length_1)
49/// PMO    = ema(10 * stage1,  alpha = 2/length_2)
50/// signal = Ema(signal_len) over the published PMO values
51/// ```
52///
53/// Two details make this its own indicator rather than a smoothed rate of change. The smoothing
54/// constant is `2/length`, not the `2/(length + 1)` of an ordinary exponential average — a
55/// `length_1 = 35` here reacts like a 34-period ordinary EMA, and treating the two as the same
56/// would quietly shift every period a caller configures. And the factor 10 sits *between* the two
57/// stages, which is why the output is an index rather than a percentage: the value is ten times a
58/// twice-smoothed percentage return.
59///
60/// The signal line, in contrast, is an ordinary [`Ema`] over the finished values.
61///
62/// Per-bar outputs, in index points:
63/// - `value`: the PMO line.
64/// - `extra["signal"]`: present from the `signal_len`-th published value on.
65///
66/// Both stages run from the first return on: the first is seeded with the first return, the
67/// second with ten times the first output of the first, and each takes every value the stage
68/// before it produces. Publication waits instead: the line appears once
69/// `length_1 + length_2 - 1` returns have passed through both stages, i.e. with the
70/// `length_1 + length_2`-th bar. There is no output on the first bar of a series, since a return
71/// needs a predecessor. The signal line only ever sees published values. [`Indicator::reset`]
72/// clears both stages and the signal average.
73#[derive(Debug, Clone)]
74pub struct PriceMomentumOscillator {
75    length_1: usize,
76    length_2: usize,
77    signal_len: usize,
78    prev_close: Option<f64>,
79    stage_1: DirectAlphaEma,
80    stage_2: DirectAlphaEma,
81    signal_ema: Ema,
82    observations: usize,
83    lines_published: usize,
84}
85
86impl PriceMomentumOscillator {
87    pub fn new(length_1: usize, length_2: usize, signal_len: usize) -> Self {
88        Self {
89            length_1: length_1.max(1),
90            length_2: length_2.max(1),
91            signal_len: signal_len.max(1),
92            prev_close: None,
93            stage_1: DirectAlphaEma::new(length_1),
94            stage_2: DirectAlphaEma::new(length_2),
95            signal_ema: Ema::new(signal_len.max(1)),
96            observations: 0,
97            lines_published: 0,
98        }
99    }
100
101    pub fn with_defaults() -> Self {
102        Self::new(35, 20, 10)
103    }
104}
105
106impl Indicator for PriceMomentumOscillator {
107    fn name(&self) -> &str {
108        "pmo"
109    }
110
111    fn warmup_period(&self) -> usize {
112        self.length_1 + self.length_2
113    }
114
115    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
116        let prev_close = match self.prev_close {
117            None => {
118                self.prev_close = Some(bar.close);
119                return None;
120            }
121            Some(previous) => previous,
122        };
123        self.prev_close = Some(bar.close);
124
125        if prev_close == 0.0 {
126            return None;
127        }
128        let roc = 100.0 * (bar.close / prev_close - 1.0);
129        let line = self.stage_2.update(10.0 * self.stage_1.update(roc));
130
131        self.observations += 1;
132        if self.observations < self.length_1 + self.length_2 - 1 {
133            return None;
134        }
135        self.lines_published += 1;
136
137        let mut extra = HashMap::new();
138        let signal = self.signal_ema.update(line)?;
139        if self.lines_published >= self.signal_len {
140            extra.insert("signal".to_string(), signal);
141        }
142
143        Some(IndicatorOutput::with_extra(line, extra))
144    }
145
146    fn reset(&mut self) {
147        self.prev_close = None;
148        self.stage_1.reset();
149        self.stage_2.reset();
150        self.signal_ema.reset();
151        self.observations = 0;
152        self.lines_published = 0;
153    }
154}