wickra_core/traits.rs
1//! Core traits: the [`Indicator`] state machine and the [`BatchExt`] blanket extension.
2
3use crate::ohlcv::Candle;
4
5/// A streaming technical indicator.
6///
7/// Every indicator in Wickra implements this trait. The contract is:
8///
9/// - [`update`](Indicator::update) is called once per input point and must be O(1) in
10/// the input length. Pre-existing buffered state may be touched, but no full
11/// recomputation over the entire series is permitted.
12/// - The returned `Option<Output>` is `None` while the indicator is still in its
13/// *warmup* phase (insufficient inputs to produce a defined value), and `Some`
14/// once it is ready.
15/// - [`reset`](Indicator::reset) clears all state, returning the indicator to the
16/// exact configuration it had immediately after construction.
17///
18/// Implementors that consume scalar prices use `Input = f64` so they automatically
19/// gain access to chaining via [`Chain`].
20pub trait Indicator {
21 /// Type of one input data point (typically `f64` for a price, or `Candle` / `Tick`).
22 type Input;
23 /// Type of one output value.
24 type Output;
25
26 /// Feed one new data point into the indicator and return the freshly
27 /// computed output, or `None` if there is no value for this input.
28 ///
29 /// `None` covers exactly two cases:
30 ///
31 /// * the indicator is still warming up, and
32 /// * the input was rejected as non-finite.
33 ///
34 /// A rejected input is *skipped*: it does not enter the indicator's state,
35 /// so a single bad tick cannot corrupt the values that follow it. The
36 /// alternative — repeating the last computed value — was rejected because
37 /// it hands the caller a stale number that looks exactly like a fresh one.
38 fn update(&mut self, input: Self::Input) -> Option<Self::Output>;
39
40 /// Reset all internal state, leaving the indicator equivalent to a freshly
41 /// constructed instance with the same parameters.
42 fn reset(&mut self);
43
44 /// Number of inputs required before the first non-`None` output can be produced.
45 fn warmup_period(&self) -> usize;
46
47 /// Whether the indicator has emitted at least one value since the last reset.
48 fn is_ready(&self) -> bool;
49
50 /// Stable, human-readable indicator name. Used by chaining and diagnostics.
51 fn name(&self) -> &'static str;
52}
53
54/// Blanket extension that adds batch evaluation to every [`Indicator`].
55///
56/// The naive `batch` simply replays `update` over a slice, which is always correct
57/// because `update` is the only state transition. Concrete indicators may override
58/// `batch` if they have a faster vectorized path; the default keeps the contract
59/// `batch == repeated update`.
60pub trait BatchExt: Indicator {
61 /// Run the indicator over a slice of inputs in order, returning one output (or
62 /// `None` during warmup) per input.
63 fn batch(&mut self, inputs: &[Self::Input]) -> Vec<Option<Self::Output>>
64 where
65 Self::Input: Clone,
66 {
67 let mut out = Vec::with_capacity(inputs.len());
68 for x in inputs {
69 out.push(self.update(x.clone()));
70 }
71 out
72 }
73
74 /// Run an independent copy of the indicator over each input series in parallel.
75 ///
76 /// Each asset is processed by its own fresh instance built via `make`, so state
77 /// never leaks across assets. Requires the `parallel` feature (enabled by
78 /// default), which pulls in `rayon`.
79 #[cfg(feature = "parallel")]
80 fn batch_parallel<F>(
81 inputs_per_asset: &[Vec<Self::Input>],
82 make: F,
83 ) -> Vec<Vec<Option<Self::Output>>>
84 where
85 Self: Sized + Send,
86 Self::Input: Sync + Clone,
87 Self::Output: Send,
88 F: Fn() -> Self + Sync + Send,
89 {
90 use rayon::prelude::*;
91 inputs_per_asset
92 .par_iter()
93 .map(|series| {
94 let mut ind = make();
95 ind.batch(series)
96 })
97 .collect()
98 }
99}
100
101impl<T: Indicator> BatchExt for T {}
102
103/// Fast batch for scalar `f64 -> f64` indicators.
104///
105/// The generic [`BatchExt::batch`] returns `Vec<Option<f64>>` — 16 bytes per
106/// element (no niche fits an arbitrary `f64`), which a caller wanting a dense
107/// `f64` series then has to walk a second time to map warmup `None`s to `NaN`.
108/// This skips both the wide intermediate and the second pass: one allocation,
109/// one pass, warmup encoded as `NaN`. The default body is bit-identical to
110/// replaying `update`; indicators with a vectorizable closed form override it
111/// with an inherent `batch_nan` of the same name, which wins method resolution
112/// over this trait default.
113pub trait BatchNanExt: Indicator<Input = f64, Output = f64> {
114 /// One `f64` per input, warmup positions filled with `NaN`.
115 fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
116 let mut out = Vec::with_capacity(inputs.len());
117 for &x in inputs {
118 out.push(self.update(x).unwrap_or(f64::NAN));
119 }
120 out
121 }
122}
123
124impl<T: Indicator<Input = f64, Output = f64>> BatchNanExt for T {}
125
126/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi,
127/// Point-and-Figure) that turns a candle stream into a stream of price-driven
128/// bars.
129///
130/// Bar builders are deliberately **not** [`Indicator`]s: a single input candle
131/// may complete zero, one, or many bars (a large move can print several Renko
132/// bricks at once), which breaks the `update -> Option<Output>` one-in-one-out
133/// contract and the `batch == repeated update` length invariant. They get their
134/// own trait instead, returning a `Vec` of freshly completed bars per candle.
135///
136/// The contract is:
137///
138/// - [`update`](BarBuilder::update) ingests one candle and returns every bar it
139/// *completed* on that candle, in chronological order. An empty vector means
140/// the move was not large enough to finish a bar yet.
141/// - [`reset`](BarBuilder::reset) clears all state, returning the builder to the
142/// configuration it had immediately after construction.
143/// - [`batch`](BarBuilder::batch) concatenates the bars from replaying `update`
144/// over a slice; the flattened length is data-dependent, not the input length.
145///
146/// Bar builders cannot participate in [`Chain`] (which requires
147/// `Indicator<Input = f64, Output = f64>`); feed a downstream indicator from the
148/// bars' close prices manually if you need to chain off them.
149///
150/// ```text
151/// let mut renko = RenkoBars::new(1.0).unwrap();
152/// let bricks = renko.update(candle); // Vec<RenkoBrick>: 0..n completed bricks
153/// ```
154pub trait BarBuilder {
155 /// Type of one completed bar.
156 type Bar;
157
158 /// Feed one candle and return every bar completed on it (possibly none).
159 fn update(&mut self, candle: Candle) -> Vec<Self::Bar>;
160
161 /// Reset all internal state to the freshly-constructed configuration.
162 fn reset(&mut self);
163
164 /// Stable, human-readable builder name.
165 fn name(&self) -> &'static str;
166
167 /// Replay `update` over a slice, concatenating all completed bars. The
168 /// result length is data-dependent (not the input length).
169 fn batch(&mut self, candles: &[Candle]) -> Vec<Self::Bar> {
170 let mut out = Vec::new();
171 for candle in candles {
172 out.extend(self.update(*candle));
173 }
174 out
175 }
176}
177
178/// Chain two indicators so the output of the first becomes the input of the second.
179///
180/// Both indicators must agree on `f64` as the bridging type, which is the common
181/// case for price-in/value-out indicators. The chain itself is an indicator, so
182/// chains can be nested arbitrarily.
183///
184/// # Example
185///
186/// ```
187/// use wickra_core::{Chain, Ema, Indicator, Rsi};
188///
189/// // RSI(7) on top of EMA(14). EMA seeds at input 14, then RSI needs 7+1 more
190/// // valid inputs to emit, so the chain becomes ready at input 21.
191/// let mut chain = Chain::new(Ema::new(14).unwrap(), Rsi::new(7).unwrap());
192/// for i in 1..=21 {
193/// chain.update(f64::from(i));
194/// }
195/// assert!(chain.is_ready());
196/// ```
197#[derive(Debug, Clone)]
198pub struct Chain<A, B>
199where
200 A: Indicator<Input = f64, Output = f64>,
201 B: Indicator<Input = f64>,
202{
203 first: A,
204 second: B,
205}
206
207impl<A, B> Chain<A, B>
208where
209 A: Indicator<Input = f64, Output = f64>,
210 B: Indicator<Input = f64>,
211{
212 /// Construct a chain whose inputs flow through `first` and then `second`.
213 pub const fn new(first: A, second: B) -> Self {
214 Self { first, second }
215 }
216
217 /// Add a third stage on top.
218 pub fn then<C>(self, third: C) -> Chain<Self, C>
219 where
220 C: Indicator<Input = f64>,
221 Self: Indicator<Input = f64, Output = f64>,
222 {
223 Chain::new(self, third)
224 }
225
226 /// Borrow the upstream indicator.
227 pub const fn first(&self) -> &A {
228 &self.first
229 }
230
231 /// Borrow the downstream indicator.
232 pub const fn second(&self) -> &B {
233 &self.second
234 }
235}
236
237impl<A, B> Indicator for Chain<A, B>
238where
239 A: Indicator<Input = f64, Output = f64>,
240 B: Indicator<Input = f64>,
241{
242 type Input = f64;
243 type Output = B::Output;
244
245 fn update(&mut self, input: f64) -> Option<Self::Output> {
246 self.first.update(input).and_then(|v| self.second.update(v))
247 }
248
249 fn reset(&mut self) {
250 self.first.reset();
251 self.second.reset();
252 }
253
254 fn warmup_period(&self) -> usize {
255 // Not an upper bound: this method promises the input count before the
256 // first value, so over-declaring it is as wrong as under-declaring it.
257 // The second stage receives its first input on the bar the first stage
258 // emits, so the two warmups overlap by exactly one.
259 // A stage declaring 0 still needs its first input to produce anything,
260 // so each side counts as at least one bar before the overlap is taken
261 // off -- otherwise two pass-through stages underflow.
262 self.first.warmup_period().max(1) + self.second.warmup_period().max(1) - 1
263 }
264
265 fn is_ready(&self) -> bool {
266 self.first.is_ready() && self.second.is_ready()
267 }
268
269 fn name(&self) -> &'static str {
270 "Chain"
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 /// A trivial test indicator: identity (passes input through).
279 #[derive(Debug, Default)]
280 struct Identity {
281 seen: bool,
282 }
283
284 impl Indicator for Identity {
285 type Input = f64;
286 type Output = f64;
287 fn update(&mut self, input: f64) -> Option<f64> {
288 self.seen = true;
289 Some(input)
290 }
291 fn reset(&mut self) {
292 self.seen = false;
293 }
294 fn warmup_period(&self) -> usize {
295 0
296 }
297 fn is_ready(&self) -> bool {
298 self.seen
299 }
300 fn name(&self) -> &'static str {
301 "Identity"
302 }
303 }
304
305 /// Another trivial test indicator: scales input by 2.
306 #[derive(Debug, Default)]
307 struct Doubler {
308 seen: bool,
309 }
310
311 impl Indicator for Doubler {
312 type Input = f64;
313 type Output = f64;
314 fn update(&mut self, input: f64) -> Option<f64> {
315 self.seen = true;
316 Some(input * 2.0)
317 }
318 fn reset(&mut self) {
319 self.seen = false;
320 }
321 fn warmup_period(&self) -> usize {
322 0
323 }
324 fn is_ready(&self) -> bool {
325 self.seen
326 }
327 fn name(&self) -> &'static str {
328 "Doubler"
329 }
330 }
331
332 #[test]
333 fn batch_replays_update() {
334 let mut id = Identity::default();
335 let out = id.batch(&[1.0, 2.0, 3.0]);
336 assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]);
337 }
338
339 /// The blanket [`BatchNanExt::batch_nan`] default (used by every scalar
340 /// indicator without an inherent fast path) maps `update` outputs to a dense
341 /// `f64` series, warmup `None` becoming `NaN`. `Identity` is always ready, so
342 /// the result is just the inputs back.
343 #[test]
344 fn batch_nan_default_maps_none_to_nan() {
345 let mut id = Identity::default();
346 let out = id.batch_nan(&[1.0, 2.0, 3.0]);
347 assert_eq!(out, vec![1.0, 2.0, 3.0]);
348 }
349
350 #[test]
351 fn chain_pipes_first_into_second() {
352 let mut c = Chain::new(Doubler::default(), Doubler::default());
353 // 5 -> 10 -> 20
354 assert_eq!(c.update(5.0), Some(20.0));
355 }
356
357 #[test]
358 fn chain_is_ready_only_after_both_stages_emit() {
359 let mut c = Chain::new(Doubler::default(), Doubler::default());
360 assert!(!c.is_ready());
361 c.update(1.0);
362 assert!(c.is_ready());
363 }
364
365 #[test]
366 fn chain_reset_propagates() {
367 let mut c = Chain::new(Doubler::default(), Doubler::default());
368 c.update(1.0);
369 assert!(c.is_ready());
370 c.reset();
371 assert!(!c.is_ready());
372 }
373
374 #[test]
375 fn chain_three_levels_via_then() {
376 let c = Chain::new(Doubler::default(), Doubler::default()).then(Doubler::default());
377 let mut c = c;
378 // 1 -> 2 -> 4 -> 8
379 assert_eq!(c.update(1.0), Some(8.0));
380 }
381
382 /// Cover the `Chain::first` / `Chain::second` borrow accessors and the
383 /// `Chain::warmup_period` + `Chain::name` Indicator-impl bodies.
384 ///
385 /// Existing chain tests only invoked the Indicator surface (`update`,
386 /// `reset`, `is_ready`) on the wrapped `Chain`. The const borrow accessors
387 /// and the `warmup_period` / `name` impls were never traversed, so Codecov
388 /// flagged traits.rs lines 140-142, 145-147, 167-170, 176-178 as missed.
389 /// `chain.warmup_period()` also reaches `Doubler::warmup_period`
390 /// (228-230), and `chain.first().name()` reaches `Doubler::name`
391 /// (234-236) — both helper methods were uncovered for the same reason.
392 #[test]
393 fn chain_accessors_and_metadata() {
394 let chain = Chain::new(Doubler::default(), Doubler::default());
395 // Borrow accessors return the wrapped stages; query each via .name()
396 // so Doubler::name (lines 234-236) is also exercised.
397 assert_eq!(chain.first().name(), "Doubler");
398 assert_eq!(chain.second().name(), "Doubler");
399 // Doubler::warmup_period (lines 228-230) is 0, meaning it emits on its
400 // first input; chaining two of them still emits on the first input.
401 assert_eq!(chain.first().warmup_period(), 0);
402 assert_eq!(chain.second().warmup_period(), 0);
403 assert_eq!(chain.warmup_period(), 1);
404 // Chain::name returns the literal "Chain" (line 177).
405 assert_eq!(chain.name(), "Chain");
406 }
407
408 /// Cover the full Indicator surface of the `Identity` test helper:
409 /// `reset` (198-200), `warmup_period` (201-203), `is_ready` (204-206),
410 /// and `name` (207-209). The only other test using `Identity`
411 /// (`batch_replays_update`) calls `batch`, which exercises `update`
412 /// alone, leaving the remaining four trait methods uncovered.
413 #[test]
414 fn identity_helper_full_indicator_surface() {
415 let mut id = Identity::default();
416 // warmup_period is the literal 0; name is the literal "Identity".
417 assert_eq!(id.warmup_period(), 0);
418 assert_eq!(id.name(), "Identity");
419 // is_ready exercises the `self.seen` return with seen=false first…
420 assert!(!id.is_ready());
421 // …then with seen=true after a single update.
422 let out = id.update(42.0);
423 assert_eq!(out, Some(42.0));
424 assert!(id.is_ready());
425 // reset() flips seen back to false; is_ready reflects it.
426 id.reset();
427 assert!(!id.is_ready());
428 }
429
430 #[cfg(feature = "parallel")]
431 #[test]
432 fn batch_parallel_runs_independent_instances() {
433 let series: Vec<Vec<f64>> = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
434 let out = Doubler::batch_parallel(&series, Doubler::default);
435 assert_eq!(out.len(), 2);
436 assert_eq!(out[0], vec![Some(2.0), Some(4.0), Some(6.0)]);
437 assert_eq!(out[1], vec![Some(8.0), Some(10.0), Some(12.0)]);
438 }
439}