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}