kestrel_chartkit/indicator/trix.rs
1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::Ema;
6use super::{Indicator, IndicatorOutput};
7
8/// TRIX: the rate of change of a triple-smoothed price series.
9///
10/// Three exponential averages of `len` are chained over the close — `e1` over the price, `e2`
11/// over `e1`, `e3` over `e2` — and the published line is the relative change of that third
12/// average against its own previous value:
13///
14/// `TRIX_t = 100 * (e3_t / e3_{t-1} - 1)`
15///
16/// The previous value is the denominator, so the result is the percentage change *of the level
17/// it came from*. Other implementations divide by the current value or publish the absolute
18/// difference instead; those are different numbers under the same name, and this type does not
19/// offer them.
20///
21/// This is not TEMA: TEMA combines the three averages linearly to reduce lag and stays in price
22/// units, while TRIX differentiates the third average and is a rate.
23///
24/// Every stage uses the shared [`Ema`] with its first-sample seed, so each stage always receives
25/// a defined input — no stage is ever fed a substituted zero.
26///
27/// Units: percentage points per bar for the line, the signal and the histogram alike.
28///
29/// Per-bar outputs:
30/// - `value`: the TRIX line.
31/// - `extra["signal"]`: `Ema(signal_len)` over the published TRIX values, seeded with the first
32/// of them and published only from the `signal_len`-th on. Before that the key is absent — an
33/// unfinished signal line is left out, not zeroed.
34/// - `extra["hist"]`: `value - signal`, present exactly when the signal is.
35///
36/// First output: with the `len + 1`-th bar, so both `e3` values of the ratio are built from at
37/// least `len` inputs each. A bar whose previous `e3` is zero or non-finite produces no output
38/// rather than a division result that means nothing. [`Indicator::reset`] clears all four
39/// averages and the counters, so the next series starts deterministically.
40#[derive(Debug, Clone)]
41pub struct Trix {
42 len: usize,
43 signal_len: usize,
44 ema1: Ema,
45 ema2: Ema,
46 ema3: Ema,
47 prev_ema3: Option<f64>,
48 signal_ema: Ema,
49 bars_seen: usize,
50 lines_published: usize,
51}
52
53impl Trix {
54 pub fn new(len: usize, signal_len: usize) -> Self {
55 let len = len.max(1);
56 Self {
57 len,
58 signal_len: signal_len.max(1),
59 ema1: Ema::new(len),
60 ema2: Ema::new(len),
61 ema3: Ema::new(len),
62 prev_ema3: None,
63 signal_ema: Ema::new(signal_len.max(1)),
64 bars_seen: 0,
65 lines_published: 0,
66 }
67 }
68
69 pub fn with_defaults() -> Self {
70 Self::new(15, 9)
71 }
72}
73
74impl Indicator for Trix {
75 fn name(&self) -> &str {
76 "trix"
77 }
78
79 fn warmup_period(&self) -> usize {
80 self.len + 1
81 }
82
83 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
84 self.bars_seen += 1;
85
86 let e1 = self.ema1.update(bar.close)?;
87 let e2 = self.ema2.update(e1)?;
88 let e3 = self.ema3.update(e2)?;
89
90 let prev_ema3 = self.prev_ema3.replace(e3);
91 if self.bars_seen < self.len + 1 {
92 return None;
93 }
94 let prev_ema3 = prev_ema3?;
95 if prev_ema3 == 0.0 || !prev_ema3.is_finite() {
96 return None;
97 }
98
99 let line = 100.0 * (e3 / prev_ema3 - 1.0);
100 self.lines_published += 1;
101
102 let mut extra = HashMap::new();
103 let signal = self.signal_ema.update(line)?;
104 if self.lines_published >= self.signal_len {
105 extra.insert("signal".to_string(), signal);
106 extra.insert("hist".to_string(), line - signal);
107 }
108
109 Some(IndicatorOutput::with_extra(line, extra))
110 }
111
112 fn reset(&mut self) {
113 self.ema1.reset();
114 self.ema2.reset();
115 self.ema3.reset();
116 self.signal_ema.reset();
117 self.prev_ema3 = None;
118 self.bars_seen = 0;
119 self.lines_published = 0;
120 }
121}