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//! the usual convention, where a `source` parameter is only ever offered on scalar-series
16//! functions.
17
18use super::{Indicator, IndicatorAlert, IndicatorOutput};
19use crate::model::{Bar, BarValidationError, Source};
20
21/// Wraps `inner`, feeding it a synthetic bar of `source.extract(bar)` on every OHLC field
22/// (volume unchanged) instead of the original bar.
23pub struct SourceMapped<I: Indicator> {
24    inner: I,
25    source: Source,
26}
27
28impl<I: Indicator> SourceMapped<I> {
29    pub fn new(inner: I, source: Source) -> Self {
30        Self { inner, source }
31    }
32
33    fn map_bar(&self, bar: &Bar) -> Bar {
34        let value = self.source.extract(bar);
35        Bar::new(bar.timestamp, value, value, value, value, bar.volume)
36    }
37}
38
39impl<I: Indicator> Indicator for SourceMapped<I> {
40    fn name(&self) -> &str {
41        self.inner.name()
42    }
43    fn warmup_period(&self) -> usize {
44        self.inner.warmup_period()
45    }
46    fn reset(&mut self) {
47        self.inner.reset()
48    }
49    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
50        let mapped = self.map_bar(bar);
51        self.inner.on_bar(&mapped)
52    }
53    fn on_checked_bar(&mut self, bar: &Bar) -> Result<Option<IndicatorOutput>, BarValidationError> {
54        bar.validate()?;
55        Ok(self.on_bar(bar))
56    }
57    fn alerts(&self) -> Vec<IndicatorAlert> {
58        self.inner.alerts()
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::indicator::moving_averages::SmaEngine;
66
67    #[test]
68    fn test_source_mapped_uses_selected_source_not_close() {
69        let mut close_sma = SmaEngine::new(2);
70        let mut open_sma = SourceMapped::new(SmaEngine::new(2), Source::Open);
71
72        // open/close deliberately diverge so the two SMAs must disagree once source-mapped.
73        let bars = [
74            Bar::new(0, 10.0, 12.0, 8.0, 11.0, 100.0),
75            Bar::new(60, 20.0, 22.0, 18.0, 21.0, 100.0),
76        ];
77
78        let mut close_out = None;
79        let mut open_out = None;
80        for bar in &bars {
81            close_out = close_sma.on_bar(bar);
82            open_out = open_sma.on_bar(bar);
83        }
84
85        assert_eq!(close_out.unwrap().value, (11.0 + 21.0) / 2.0);
86        assert_eq!(open_out.unwrap().value, (10.0 + 20.0) / 2.0);
87    }
88
89    #[test]
90    fn test_source_mapped_preserves_volume_and_delegates_metadata() {
91        let mut hl2_sma = SourceMapped::new(SmaEngine::new(1), Source::Hl2);
92        let bar = Bar::new(0, 10.0, 20.0, 0.0, 15.0, 250.0);
93        // Hl2 = (high + low) / 2 = 10.0
94        let out = hl2_sma.on_bar(&bar).unwrap();
95        assert_eq!(out.value, 10.0);
96        assert_eq!(hl2_sma.name(), "sma");
97        assert_eq!(hl2_sma.warmup_period(), SmaEngine::new(1).warmup_period());
98    }
99}