Skip to main content

wickra_core/indicators/
volume_bars.rs

1//! Volume bar builder — close a bar each time accumulated volume reaches a threshold.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::BarBuilder;
6
7/// One completed volume bar (an OHLCV aggregate spanning ~`volume_per_bar` of volume).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct VolumeBar {
10    /// Open of the first candle in the bar.
11    pub open: f64,
12    /// Highest high across the bar.
13    pub high: f64,
14    /// Lowest low across the bar.
15    pub low: f64,
16    /// Close of the candle that closed the bar.
17    pub close: f64,
18    /// Accumulated volume in the bar (`>= volume_per_bar`; the crossing candle's
19    /// overshoot is kept in the bar that closes).
20    pub volume: f64,
21}
22
23/// Volume bar builder — emits a bar each time accumulated volume reaches
24/// `volume_per_bar`.
25///
26/// Where [`TickBars`](crate::TickBars) sample on trade *count*, volume bars sample on
27/// traded *quantity*: a bar closes once the candles fed into it have accumulated at
28/// least `volume_per_bar` of volume. This gives each bar roughly equal participation,
29/// which de-emphasises quiet periods and resolves bursts of heavy trading into more
30/// bars. The companion [`DollarBars`](crate::DollarBars) builder uses traded *value*
31/// (`price × volume`) instead, which is more robust to price-level drift over long
32/// histories.
33///
34/// The bar is candle-granular: at most one bar closes per candle, and the candle
35/// that crosses the threshold closes the bar with its overshoot included (the next
36/// bar starts fresh). [`BarBuilder::update`] therefore returns either an empty vector
37/// or a single [`VolumeBar`].
38///
39/// # Example
40///
41/// ```
42/// use wickra_core::{BarBuilder, Candle, VolumeBars};
43///
44/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap();
45/// let mut bars = VolumeBars::new(100.0).unwrap();
46/// assert!(bars.update(c(10.0, 60.0)).is_empty());
47/// let out = bars.update(c(10.5, 60.0)); // 120 >= 100 -> close
48/// assert_eq!(out.len(), 1);
49/// assert_eq!(out[0].volume, 120.0);
50/// ```
51#[derive(Debug, Clone)]
52pub struct VolumeBars {
53    volume_per_bar: f64,
54    count: usize,
55    open: f64,
56    high: f64,
57    low: f64,
58    close: f64,
59    accumulated: f64,
60}
61
62impl VolumeBars {
63    /// Construct a volume-bar builder with the given volume threshold.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::InvalidPeriod`] if `volume_per_bar` is not finite and positive.
68    pub fn new(volume_per_bar: f64) -> Result<Self> {
69        if !volume_per_bar.is_finite() || volume_per_bar <= 0.0 {
70            return Err(Error::InvalidPeriod {
71                message: "volume_per_bar must be finite and positive",
72            });
73        }
74        Ok(Self {
75            volume_per_bar,
76            count: 0,
77            open: 0.0,
78            high: 0.0,
79            low: 0.0,
80            close: 0.0,
81            accumulated: 0.0,
82        })
83    }
84
85    /// Configured volume threshold per bar.
86    pub const fn volume_per_bar(&self) -> f64 {
87        self.volume_per_bar
88    }
89
90    /// Volume accumulated into the in-progress bar.
91    pub const fn accumulated(&self) -> f64 {
92        self.accumulated
93    }
94}
95
96impl BarBuilder for VolumeBars {
97    type Bar = VolumeBar;
98
99    #[inline]
100    fn update(&mut self, candle: Candle) -> Vec<VolumeBar> {
101        if self.count == 0 {
102            self.open = candle.open;
103            self.high = candle.high;
104            self.low = candle.low;
105        } else {
106            self.high = self.high.max(candle.high);
107            self.low = self.low.min(candle.low);
108        }
109        self.close = candle.close;
110        self.accumulated += candle.volume;
111        self.count += 1;
112        if self.accumulated < self.volume_per_bar {
113            return Vec::new();
114        }
115        let bar = VolumeBar {
116            open: self.open,
117            high: self.high,
118            low: self.low,
119            close: self.close,
120            volume: self.accumulated,
121        };
122        self.count = 0;
123        self.accumulated = 0.0;
124        vec![bar]
125    }
126
127    fn reset(&mut self) {
128        self.count = 0;
129        self.accumulated = 0.0;
130    }
131
132    #[inline]
133    fn name(&self) -> &'static str {
134        "VolumeBars"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use approx::assert_relative_eq;
142
143    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
144        Candle::new(open, high, low, close, volume, 0).unwrap()
145    }
146
147    #[test]
148    fn rejects_invalid_threshold() {
149        assert!(matches!(
150            VolumeBars::new(0.0),
151            Err(Error::InvalidPeriod { .. })
152        ));
153        assert!(matches!(
154            VolumeBars::new(-100.0),
155            Err(Error::InvalidPeriod { .. })
156        ));
157        assert!(matches!(
158            VolumeBars::new(f64::INFINITY),
159            Err(Error::InvalidPeriod { .. })
160        ));
161    }
162
163    #[test]
164    fn accessors_and_metadata() {
165        let bars = VolumeBars::new(1000.0).unwrap();
166        assert_relative_eq!(bars.volume_per_bar(), 1000.0, epsilon = 1e-12);
167        assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
168        assert_eq!(bars.name(), "VolumeBars");
169    }
170
171    #[test]
172    fn closes_when_threshold_reached() {
173        let mut bars = VolumeBars::new(100.0).unwrap();
174        assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty());
175        let out = bars.update(candle(10.5, 10.5, 10.5, 10.5, 60.0));
176        assert_eq!(out.len(), 1);
177        assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12);
178    }
179
180    #[test]
181    fn aggregates_ohlc() {
182        let mut bars = VolumeBars::new(100.0).unwrap();
183        bars.update(candle(10.0, 11.0, 9.0, 10.5, 50.0));
184        let out = bars.update(candle(10.5, 12.0, 10.0, 11.0, 60.0));
185        assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
186        assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
187        assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
188        assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12);
189    }
190
191    #[test]
192    fn below_threshold_emits_nothing() {
193        let mut bars = VolumeBars::new(100.0).unwrap();
194        bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0));
195        assert_relative_eq!(bars.accumulated(), 30.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn reset_clears_state() {
200        let mut bars = VolumeBars::new(100.0).unwrap();
201        bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0));
202        bars.reset();
203        assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
204        assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 60.0)).is_empty());
205    }
206
207    #[test]
208    fn batch_concatenates_completed_bars() {
209        let mut bars = VolumeBars::new(100.0).unwrap();
210        let candles = [
211            candle(10.0, 10.0, 10.0, 10.0, 60.0),
212            candle(10.0, 10.0, 10.0, 10.0, 60.0),
213            candle(10.0, 10.0, 10.0, 10.0, 60.0),
214            candle(10.0, 10.0, 10.0, 10.0, 60.0),
215        ];
216        let out = bars.batch(&candles);
217        assert_eq!(out.len(), 2);
218    }
219}