1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
//! TRIX: triple-smoothed EMA percent rate of change.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// TRIX: the 1-period percent rate of change of a triple-smoothed EMA.
///
/// `TRIX = 100 * (TR_t - TR_{t-1}) / TR_{t-1}` where
/// `TR_t = EMA(EMA(EMA(price)))`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Trix};
///
/// let mut indicator = Trix::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Trix {
ema1: Ema,
ema2: Ema,
ema3: Ema,
prev_tr: Option<f64>,
/// Whether a value has been emitted since the last reset. `prev_tr` cannot
/// stand in for this: the bar that first fills it is the rate-of-change
/// baseline and returns `None`, so keying readiness off it would report
/// ready one input early.
has_emitted: bool,
period: usize,
}
impl Trix {
/// # Errors
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
ema3: Ema::new(period)?,
prev_tr: None,
has_emitted: false,
period,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Trix {
type Input = f64;
type Output = f64;
#[inline]
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
let e3 = self.ema3.update(e2)?;
match self.prev_tr {
Some(prev) if prev != 0.0 => {
let trix = 100.0 * (e3 - prev) / prev;
self.prev_tr = Some(e3);
self.has_emitted = true;
Some(trix)
}
Some(_) => {
self.prev_tr = Some(e3);
self.has_emitted = true;
Some(0.0)
}
None => {
self.prev_tr = Some(e3);
None
}
}
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
self.ema3.reset();
self.prev_tr = None;
self.has_emitted = false;
}
#[inline]
fn warmup_period(&self) -> usize {
// Triple EMA seeds at 3*period-2; plus one extra for the rate of change.
3 * self.period - 1
}
#[inline]
fn is_ready(&self) -> bool {
self.has_emitted
}
#[inline]
fn name(&self) -> &'static str {
"TRIX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_zero_trix() {
let mut trix = Trix::new(5).unwrap();
let out = trix.batch(&[100.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
}
#[test]
fn rising_series_eventually_positive_trix() {
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
let mut trix = Trix::new(5).unwrap();
let last = trix.batch(&prices).into_iter().flatten().last().unwrap();
assert!(last > 0.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 1.3).collect();
let mut a = Trix::new(7).unwrap();
let mut b = Trix::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut trix = Trix::new(5).unwrap();
trix.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(trix.is_ready());
trix.reset();
assert!(!trix.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Trix::new(0).is_err());
}
/// `is_ready()` used to key off `prev_tr`, which the rate-of-change baseline
/// bar fills while still returning `None` — so readiness flipped one input
/// before the first value. Pin readiness to the emission itself, and the
/// declared warmup to the index of that emission.
#[test]
fn readiness_flips_exactly_when_the_first_value_lands() {
let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i) * 0.7).collect();
let mut trix = Trix::new(3).unwrap();
let mut first = None;
for (i, price) in prices.iter().enumerate() {
let out = trix.update(*price);
assert_eq!(out.is_some(), trix.is_ready());
if out.is_some() && first.is_none() {
first = Some(i);
}
}
assert_eq!(first.unwrap() + 1, trix.warmup_period());
}
/// Cover the const accessor `period` (47-49) and the Indicator-impl
/// `warmup_period` (84-87) + `name` (93-95). Existing tests never
/// inspect these metadata methods.
#[test]
fn accessors_and_metadata() {
let trix = Trix::new(5).unwrap();
assert_eq!(trix.period(), 5);
// Triple EMA seeds at 3*5-2 = 13; +1 for the rate-of-change pair = 14.
assert_eq!(trix.warmup_period(), 14);
assert_eq!(trix.name(), "TRIX");
}
/// Cover the `Some(_)` match arm at lines 66-68 — the degenerate path
/// where the previous triple-EMA value is exactly 0.0 (which would
/// otherwise divide by zero on the percent-rate formula). A series of
/// all-zero inputs collapses every EMA stage to 0.0, so once the
/// indicator warms up `prev_tr` is `Some(0.0)` and every subsequent
/// emission must take the fallback branch and return 0.0.
#[test]
fn zero_input_series_yields_zero_trix() {
let mut trix = Trix::new(3).unwrap();
let out = trix.batch(&[0.0_f64; 20]);
let last = out.into_iter().flatten().last().expect("emits");
assert_eq!(last, 0.0);
}
}