Skip to main content

wickra_core/indicators/
tick_bars.rs

1//! Tick bar builder — aggregate a fixed number of candles into one OHLCV bar.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::BarBuilder;
6
7/// One completed tick bar (an OHLCV aggregate of `ticks` input candles).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct TickBar {
10    /// Open of the first candle in the group.
11    pub open: f64,
12    /// Highest high across the group.
13    pub high: f64,
14    /// Lowest low across the group.
15    pub low: f64,
16    /// Close of the last candle in the group.
17    pub close: f64,
18    /// Summed volume across the group.
19    pub volume: f64,
20}
21
22/// Tick bar builder — emits one OHLCV bar for every `ticks` input candles.
23///
24/// Classic time bars (1-minute, 1-hour) sample the market on a clock; tick bars
25/// sample it on *activity* by grouping a fixed number of trades — here modelled as a
26/// fixed number of input candles. In fast markets a tick bar closes quickly; in
27/// quiet markets it takes longer, so each bar carries roughly equal information
28/// content. This is the simplest of the information-driven bar types; the
29/// [`VolumeBars`](crate::VolumeBars) and [`DollarBars`](crate::DollarBars) builders
30/// extend the idea to equal traded volume and equal traded value respectively.
31///
32/// The open is the first candle's open, the high and low are the extremes across the
33/// group, the close is the last candle's close, and the volume is the group sum.
34/// Exactly one bar completes every `ticks` candles, so [`BarBuilder::update`]
35/// returns either an empty vector or a single [`TickBar`].
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{BarBuilder, Candle, TickBars};
41///
42/// let c = |o, h, l, cl, v| Candle::new(o, h, l, cl, v, 0).unwrap();
43/// let mut bars = TickBars::new(3).unwrap();
44/// assert!(bars.update(c(10.0, 11.0, 9.0, 10.5, 100.0)).is_empty());
45/// assert!(bars.update(c(10.5, 12.0, 10.0, 11.0, 150.0)).is_empty());
46/// let out = bars.update(c(11.0, 11.5, 10.8, 11.2, 120.0));
47/// assert_eq!(out.len(), 1);
48/// assert_eq!(out[0].volume, 370.0);
49/// ```
50#[derive(Debug, Clone)]
51pub struct TickBars {
52    ticks: usize,
53    count: usize,
54    open: f64,
55    high: f64,
56    low: f64,
57    close: f64,
58    volume: f64,
59}
60
61impl TickBars {
62    /// Construct a tick-bar builder that groups `ticks` candles per bar.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::PeriodZero`] if `ticks == 0`.
67    pub fn new(ticks: usize) -> Result<Self> {
68        if ticks == 0 {
69            return Err(Error::PeriodZero);
70        }
71        if ticks > crate::error::MAX_PERIOD {
72            return Err(Error::InvalidPeriod {
73                message: crate::error::PERIOD_ABOVE_MAX,
74            });
75        }
76        Ok(Self {
77            ticks,
78            count: 0,
79            open: 0.0,
80            high: 0.0,
81            low: 0.0,
82            close: 0.0,
83            volume: 0.0,
84        })
85    }
86
87    /// Configured number of candles per bar.
88    pub const fn ticks(&self) -> usize {
89        self.ticks
90    }
91
92    /// Number of candles accumulated into the in-progress bar.
93    pub const fn count(&self) -> usize {
94        self.count
95    }
96}
97
98impl BarBuilder for TickBars {
99    type Bar = TickBar;
100
101    #[inline]
102    fn update(&mut self, candle: Candle) -> Vec<TickBar> {
103        if self.count == 0 {
104            self.open = candle.open;
105            self.high = candle.high;
106            self.low = candle.low;
107            self.volume = 0.0;
108        } else {
109            self.high = self.high.max(candle.high);
110            self.low = self.low.min(candle.low);
111        }
112        self.close = candle.close;
113        self.volume += candle.volume;
114        self.count += 1;
115        if self.count < self.ticks {
116            return Vec::new();
117        }
118        self.count = 0;
119        vec![TickBar {
120            open: self.open,
121            high: self.high,
122            low: self.low,
123            close: self.close,
124            volume: self.volume,
125        }]
126    }
127
128    fn reset(&mut self) {
129        self.count = 0;
130        self.volume = 0.0;
131    }
132
133    #[inline]
134    fn name(&self) -> &'static str {
135        "TickBars"
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use approx::assert_relative_eq;
143
144    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
145        Candle::new(open, high, low, close, volume, 0).unwrap()
146    }
147
148    #[test]
149    fn rejects_zero_ticks() {
150        assert!(matches!(TickBars::new(0), Err(Error::PeriodZero)));
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let bars = TickBars::new(5).unwrap();
156        assert_eq!(bars.ticks(), 5);
157        assert_eq!(bars.count(), 0);
158        assert_eq!(bars.name(), "TickBars");
159    }
160
161    #[test]
162    fn emits_every_n_candles() {
163        let mut bars = TickBars::new(2).unwrap();
164        assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty());
165        assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1);
166        assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty());
167        assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1);
168    }
169
170    #[test]
171    fn aggregates_ohlcv() {
172        let mut bars = TickBars::new(3).unwrap();
173        bars.update(candle(10.0, 11.0, 9.0, 10.5, 100.0));
174        bars.update(candle(10.5, 12.0, 10.0, 11.0, 150.0));
175        let out = bars.update(candle(11.0, 11.5, 10.8, 11.2, 120.0));
176        assert_eq!(out.len(), 1);
177        assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
178        assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
179        assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
180        assert_relative_eq!(out[0].close, 11.2, epsilon = 1e-12);
181        assert_relative_eq!(out[0].volume, 370.0, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn partial_group_emits_nothing() {
186        let mut bars = TickBars::new(4).unwrap();
187        bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
188        bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
189        assert_eq!(bars.count(), 2);
190    }
191
192    #[test]
193    fn reset_clears_state() {
194        let mut bars = TickBars::new(3).unwrap();
195        bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
196        bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
197        bars.reset();
198        assert_eq!(bars.count(), 0);
199        // After reset the next candle starts a fresh group.
200        assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 5.0)).is_empty());
201        assert_eq!(bars.count(), 1);
202    }
203
204    #[test]
205    fn batch_concatenates_completed_bars() {
206        let mut bars = TickBars::new(2).unwrap();
207        let candles = [
208            candle(10.0, 10.0, 10.0, 10.0, 1.0),
209            candle(10.0, 10.0, 10.0, 10.0, 1.0),
210            candle(10.0, 10.0, 10.0, 10.0, 1.0),
211            candle(10.0, 10.0, 10.0, 10.0, 1.0),
212        ];
213        let out = bars.batch(&candles);
214        assert_eq!(out.len(), 2);
215    }
216}