Skip to main content

wickra_core/indicators/
dollar_bars.rs

1//! Dollar bar builder — close a bar each time accumulated traded value reaches a threshold.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::BarBuilder;
6
7/// One completed dollar bar (an OHLC aggregate spanning ~`dollar_per_bar` of traded value).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct DollarBar {
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    /// Summed volume across the bar.
19    pub volume: f64,
20    /// Accumulated traded value (`Σ close · volume`, `>= dollar_per_bar`).
21    pub dollar: f64,
22}
23
24/// Dollar bar builder — emits a bar each time accumulated traded value
25/// (`price × volume`) reaches `dollar_per_bar`.
26///
27/// Dollar bars are the most drift-robust of the information-driven bar types. Where
28/// [`VolumeBars`](crate::VolumeBars) close on a fixed *quantity* of shares/contracts,
29/// dollar bars close on a fixed *value*: each candle contributes `close × volume` to
30/// the running total. As a market's price level rises over years, a fixed share
31/// count buys ever more value and volume bars drift in meaning; dollar bars stay
32/// economically comparable across the whole history, which is why they are the
33/// preferred sampling for long backtests and machine-learning features.
34///
35/// The bar is candle-granular: at most one bar closes per candle, and the candle
36/// that crosses the threshold closes the bar with its overshoot included.
37/// [`BarBuilder::update`] returns either an empty vector or a single [`DollarBar`].
38///
39/// # Example
40///
41/// ```
42/// use wickra_core::{BarBuilder, Candle, DollarBars};
43///
44/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap();
45/// let mut bars = DollarBars::new(1000.0).unwrap();
46/// assert!(bars.update(c(10.0, 60.0)).is_empty()); // 600
47/// let out = bars.update(c(10.0, 60.0)); // 1200 >= 1000 -> close
48/// assert_eq!(out.len(), 1);
49/// assert_eq!(out[0].dollar, 1200.0);
50/// ```
51#[derive(Debug, Clone)]
52pub struct DollarBars {
53    dollar_per_bar: f64,
54    count: usize,
55    open: f64,
56    high: f64,
57    low: f64,
58    close: f64,
59    volume: f64,
60    dollar: f64,
61}
62
63impl DollarBars {
64    /// Construct a dollar-bar builder with the given traded-value threshold.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`Error::InvalidPeriod`] if `dollar_per_bar` is not finite and positive.
69    pub fn new(dollar_per_bar: f64) -> Result<Self> {
70        if !dollar_per_bar.is_finite() || dollar_per_bar <= 0.0 {
71            return Err(Error::InvalidPeriod {
72                message: "dollar_per_bar must be finite and positive",
73            });
74        }
75        Ok(Self {
76            dollar_per_bar,
77            count: 0,
78            open: 0.0,
79            high: 0.0,
80            low: 0.0,
81            close: 0.0,
82            volume: 0.0,
83            dollar: 0.0,
84        })
85    }
86
87    /// Configured traded-value threshold per bar.
88    pub const fn dollar_per_bar(&self) -> f64 {
89        self.dollar_per_bar
90    }
91
92    /// Traded value accumulated into the in-progress bar.
93    pub const fn accumulated(&self) -> f64 {
94        self.dollar
95    }
96}
97
98impl BarBuilder for DollarBars {
99    type Bar = DollarBar;
100
101    #[inline]
102    fn update(&mut self, candle: Candle) -> Vec<DollarBar> {
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.dollar += candle.close * candle.volume;
115        self.count += 1;
116        if self.dollar < self.dollar_per_bar {
117            return Vec::new();
118        }
119        let bar = DollarBar {
120            open: self.open,
121            high: self.high,
122            low: self.low,
123            close: self.close,
124            volume: self.volume,
125            dollar: self.dollar,
126        };
127        self.count = 0;
128        self.dollar = 0.0;
129        vec![bar]
130    }
131
132    fn reset(&mut self) {
133        self.count = 0;
134        self.volume = 0.0;
135        self.dollar = 0.0;
136    }
137
138    #[inline]
139    fn name(&self) -> &'static str {
140        "DollarBars"
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use approx::assert_relative_eq;
148
149    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
150        Candle::new(open, high, low, close, volume, 0).unwrap()
151    }
152
153    #[test]
154    fn rejects_invalid_threshold() {
155        assert!(matches!(
156            DollarBars::new(0.0),
157            Err(Error::InvalidPeriod { .. })
158        ));
159        assert!(matches!(
160            DollarBars::new(-1000.0),
161            Err(Error::InvalidPeriod { .. })
162        ));
163        assert!(matches!(
164            DollarBars::new(f64::NAN),
165            Err(Error::InvalidPeriod { .. })
166        ));
167    }
168
169    #[test]
170    fn accessors_and_metadata() {
171        let bars = DollarBars::new(50_000.0).unwrap();
172        assert_relative_eq!(bars.dollar_per_bar(), 50_000.0, epsilon = 1e-6);
173        assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
174        assert_eq!(bars.name(), "DollarBars");
175    }
176
177    #[test]
178    fn closes_when_value_reached() {
179        let mut bars = DollarBars::new(1000.0).unwrap();
180        assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty()); // 600
181        let out = bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)); // 1200
182        assert_eq!(out.len(), 1);
183        assert_relative_eq!(out[0].dollar, 1200.0, epsilon = 1e-9);
184        assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12);
185    }
186
187    #[test]
188    fn aggregates_ohlc() {
189        let mut bars = DollarBars::new(1000.0).unwrap();
190        bars.update(candle(10.0, 11.0, 9.0, 10.0, 50.0)); // 500
191        let out = bars.update(candle(10.0, 12.0, 9.5, 11.0, 60.0)); // 500 + 660 = 1160
192        assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
193        assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
194        assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
195        assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn below_threshold_emits_nothing() {
200        let mut bars = DollarBars::new(1000.0).unwrap();
201        bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0)); // 300
202        assert_relative_eq!(bars.accumulated(), 300.0, epsilon = 1e-9);
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut bars = DollarBars::new(1000.0).unwrap();
208        bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0));
209        bars.reset();
210        assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
211        assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 10.0)).is_empty());
212    }
213
214    #[test]
215    fn batch_concatenates_completed_bars() {
216        let mut bars = DollarBars::new(1000.0).unwrap();
217        let candles = [
218            candle(10.0, 10.0, 10.0, 10.0, 60.0),
219            candle(10.0, 10.0, 10.0, 10.0, 60.0),
220            candle(10.0, 10.0, 10.0, 10.0, 60.0),
221            candle(10.0, 10.0, 10.0, 10.0, 60.0),
222        ];
223        let out = bars.batch(&candles);
224        assert_eq!(out.len(), 2);
225    }
226}