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