Skip to main content

kestrel_chartkit/indicator/
source_mapped.rs

1//! Generic `Source` propagation for any [`Indicator`].
2//!
3//! Every concrete indicator computes over `bar.close` (or another hardcoded OHLCV field)
4//! internally; [`Source`] existed as a type but had no way to reach an indicator's computation.
5//! [`SourceMapped`] closes that gap generically, for the whole catalog at once, instead of
6//! threading a `source` field through every indicator's internals: it rewrites each incoming bar
7//! to a synthetic one where every OHLC field equals the selected source's extracted value
8//! (volume is preserved), then forwards that to the wrapped indicator. Any indicator that reduces
9//! its input to a single per-bar scalar (moving averages, oscillators, ...) transparently starts
10//! computing over the chosen source.
11//!
12//! This intentionally does **not** make sense for indicators that need genuine OHLC range data
13//! (True Range/ATR, volume/price profiles, structure/pivot detection): source-mapping them would
14//! flatten `high == low == open == close`, degenerating their range-dependent math. That mirrors
15//! Pine itself, where a `source` parameter is only ever offered on scalar-series functions.
16
17use super::{Indicator, IndicatorAlert, IndicatorOutput};
18use crate::model::{Bar, BarValidationError, Source};
19
20/// Wraps `inner`, feeding it a synthetic bar of `source.extract(bar)` on every OHLC field
21/// (volume unchanged) instead of the original bar.
22pub struct SourceMapped<I: Indicator> {
23    inner: I,
24    source: Source,
25}
26
27impl<I: Indicator> SourceMapped<I> {
28    pub fn new(inner: I, source: Source) -> Self {
29        Self { inner, source }
30    }
31
32    fn map_bar(&self, bar: &Bar) -> Bar {
33        let value = self.source.extract(bar);
34        Bar::new(bar.timestamp, value, value, value, value, bar.volume)
35    }
36}
37
38impl<I: Indicator> Indicator for SourceMapped<I> {
39    fn name(&self) -> &str {
40        self.inner.name()
41    }
42    fn warmup_period(&self) -> usize {
43        self.inner.warmup_period()
44    }
45    fn reset(&mut self) {
46        self.inner.reset()
47    }
48    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
49        let mapped = self.map_bar(bar);
50        self.inner.on_bar(&mapped)
51    }
52    fn on_checked_bar(&mut self, bar: &Bar) -> Result<Option<IndicatorOutput>, BarValidationError> {
53        bar.validate()?;
54        Ok(self.on_bar(bar))
55    }
56    fn alerts(&self) -> Vec<IndicatorAlert> {
57        self.inner.alerts()
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::indicator::moving_averages::SmaEngine;
65
66    #[test]
67    fn test_source_mapped_uses_selected_source_not_close() {
68        let mut close_sma = SmaEngine::new(2);
69        let mut open_sma = SourceMapped::new(SmaEngine::new(2), Source::Open);
70
71        // open/close deliberately diverge so the two SMAs must disagree once source-mapped.
72        let bars = [
73            Bar::new(0, 10.0, 12.0, 8.0, 11.0, 100.0),
74            Bar::new(60, 20.0, 22.0, 18.0, 21.0, 100.0),
75        ];
76
77        let mut close_out = None;
78        let mut open_out = None;
79        for bar in &bars {
80            close_out = close_sma.on_bar(bar);
81            open_out = open_sma.on_bar(bar);
82        }
83
84        assert_eq!(close_out.unwrap().value, (11.0 + 21.0) / 2.0);
85        assert_eq!(open_out.unwrap().value, (10.0 + 20.0) / 2.0);
86    }
87
88    #[test]
89    fn test_source_mapped_preserves_volume_and_delegates_metadata() {
90        let mut hl2_sma = SourceMapped::new(SmaEngine::new(1), Source::Hl2);
91        let bar = Bar::new(0, 10.0, 20.0, 0.0, 15.0, 250.0);
92        // Hl2 = (high + low) / 2 = 10.0
93        let out = hl2_sma.on_bar(&bar).unwrap();
94        assert_eq!(out.value, 10.0);
95        assert_eq!(hl2_sma.name(), "sma");
96        assert_eq!(hl2_sma.warmup_period(), SmaEngine::new(1).warmup_period());
97    }
98}