Skip to main content

kestrel_chartkit/
intrabar.rs

1//! Lower-timeframe (intrabar) child-bar grouping.
2//!
3//! [`BarResampler`](crate::timeframe::BarResampler) goes from lower-timeframe (LTF) bars to an
4//! *aggregated* higher-timeframe (HTF) bar. Some calculations (lower-timeframe request style:
5//! intrabar delta, aggressor volume, absorption) instead need
6//! the **full ordered sequence** of LTF child bars that belong to each HTF parent bucket, not just
7//! their aggregate. [`IntrabarGrouper`] provides that, reusing the same `Timeframe::bucket_start`
8//! bucketing [`BarResampler`] uses, so the two stay consistent with each other.
9
10use crate::model::Bar;
11use crate::timeframe::{Timeframe, TimeframeError};
12
13/// A parent bucket's complete, time-ordered child-bar sequence.
14#[derive(Debug, Clone, PartialEq)]
15pub struct IntrabarGroup {
16    /// The parent bucket's open timestamp (same convention as
17    /// [`crate::timeframe::ResamplerOutput::completed_bar`]).
18    pub parent_timestamp: i64,
19    /// Child bars in arrival order. Never empty.
20    pub children: Vec<Bar>,
21}
22
23/// Groups incoming lower-timeframe (child) bars by the higher-timeframe (parent) bucket they fall
24/// into, using the same bucketing as [`crate::timeframe::BarResampler`].
25#[derive(Debug, Clone)]
26pub struct IntrabarGrouper {
27    parent_tf: Timeframe,
28    utc_offset_seconds: i32,
29    current_parent_start: Option<i64>,
30    current_children: Vec<Bar>,
31}
32
33impl IntrabarGrouper {
34    pub fn new(parent_tf: Timeframe) -> Result<Self, TimeframeError> {
35        Self::with_utc_offset(parent_tf, 0)
36    }
37
38    pub fn with_utc_offset(
39        parent_tf: Timeframe,
40        utc_offset_seconds: i32,
41    ) -> Result<Self, TimeframeError> {
42        Ok(Self {
43            parent_tf: parent_tf.validate()?,
44            utc_offset_seconds,
45            current_parent_start: None,
46            current_children: Vec::new(),
47        })
48    }
49
50    pub fn reset(&mut self) {
51        self.current_parent_start = None;
52        self.current_children.clear();
53    }
54
55    /// Feeds one lower-timeframe child bar. Returns the *previous* parent bucket's complete,
56    /// ordered child sequence once a child bar belonging to a new parent bucket arrives — the
57    /// child-bar analogue of [`crate::timeframe::BarResampler::on_bar`]'s `completed_bar`.
58    pub fn on_child_bar(&mut self, bar: &Bar) -> Option<IntrabarGroup> {
59        let parent_start = self
60            .parent_tf
61            .bucket_start(bar.timestamp, self.utc_offset_seconds);
62
63        let completed = match self.current_parent_start {
64            Some(start) if start == parent_start => None,
65            Some(start) => Some(IntrabarGroup {
66                parent_timestamp: start,
67                children: std::mem::take(&mut self.current_children),
68            }),
69            None => None,
70        };
71
72        if completed.is_some() || self.current_parent_start.is_none() {
73            self.current_parent_start = Some(parent_start);
74        }
75        self.current_children.push(bar.clone());
76
77        completed
78    }
79
80    /// The still-forming parent bucket's child bars so far. Grows/repaints as more child bars
81    /// arrive, mirroring [`crate::timeframe::ResamplerOutput::current_unconfirmed`]'s lookahead
82    /// caveat: only [`IntrabarGrouper::on_child_bar`]'s returned [`IntrabarGroup`] is confirmed.
83    pub fn current_children(&self) -> &[Bar] {
84        &self.current_children
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    fn child_bar(ts: i64) -> Bar {
93        Bar::new(ts, 100.0, 101.0, 99.0, 100.0, 10.0)
94    }
95
96    #[test]
97    fn test_groups_children_uniquely_under_parent_bucket() {
98        let mut grouper = IntrabarGrouper::new(Timeframe::Minute(5)).unwrap();
99
100        // Five 1-minute children belong to the same 5-minute parent bucket [0, 300).
101        for ts in [0, 60, 120, 180, 240] {
102            let completed = grouper.on_child_bar(&child_bar(ts));
103            assert!(completed.is_none());
104        }
105        assert_eq!(grouper.current_children().len(), 5);
106
107        // A child at t=300 starts the next parent bucket, completing the first.
108        let completed = grouper.on_child_bar(&child_bar(300)).unwrap();
109        assert_eq!(completed.parent_timestamp, 0);
110        assert_eq!(completed.children.len(), 5);
111        assert_eq!(
112            completed
113                .children
114                .iter()
115                .map(|b| b.timestamp)
116                .collect::<Vec<_>>(),
117            vec![0, 60, 120, 180, 240]
118        );
119
120        // The new bucket now holds exactly the t=300 child.
121        assert_eq!(grouper.current_children().len(), 1);
122    }
123
124    #[test]
125    fn test_reset_clears_in_progress_group() {
126        let mut grouper = IntrabarGrouper::new(Timeframe::Minute(5)).unwrap();
127        grouper.on_child_bar(&child_bar(0));
128        assert_eq!(grouper.current_children().len(), 1);
129
130        grouper.reset();
131        assert!(grouper.current_children().is_empty());
132
133        // After reset, the next child starts a fresh bucket rather than completing the old one.
134        let completed = grouper.on_child_bar(&child_bar(600));
135        assert!(completed.is_none());
136        assert_eq!(grouper.current_children().len(), 1);
137    }
138
139    #[test]
140    fn test_consistent_with_bar_resampler_bucketing() {
141        use crate::timeframe::BarResampler;
142
143        let mut resampler = BarResampler::new(Timeframe::Minute(5)).unwrap();
144        let mut grouper = IntrabarGrouper::new(Timeframe::Minute(5)).unwrap();
145
146        let mut resampled_completed = None;
147        let mut grouped_completed = None;
148        for ts in [0, 60, 120, 180, 240, 300] {
149            let bar = child_bar(ts);
150            if let Some(c) = resampler.on_bar(&bar).completed_bar {
151                resampled_completed = Some(c);
152            }
153            if let Some(g) = grouper.on_child_bar(&bar) {
154                grouped_completed = Some(g);
155            }
156        }
157
158        assert_eq!(
159            resampled_completed.unwrap().timestamp,
160            grouped_completed.unwrap().parent_timestamp
161        );
162    }
163}