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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::intrabar::IntrabarGroup;
use crate::model::Bar;
/// Where a bar's split into buying and selling volume came from.
///
/// The three are not interchangeable, and the whole point of naming them is that a consumer can
/// tell which one it is looking at. A cumulative delta built on the first reads like one built on
/// the third, and only this label says otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum DeltaProvenance {
/// Estimated from the shape of a single bar — where the close sits inside its range. Needs
/// nothing but the bar itself, and cannot distinguish aggressive buying from selling into
/// absorption: both can close at the high. This is what [`super::volume_flow::CvdEngine`]
/// does, and it remains this crate's default.
BarShape,
/// Estimated from the sequence of lower-timeframe bars inside the parent bar: each child's
/// direction decides where its volume is booked. Finer than [`DeltaProvenance::BarShape`] —
/// a reversal inside the bar becomes visible — but still an estimate, because a child bar is
/// itself an aggregate and its own direction is inferred, not observed.
IntrabarShape,
/// Measured from classified individual trades, where each print carries the side that
/// initiated it. The only one of the three that measures the aggressor rather than inferring
/// it. No indicator in this crate produces it; the variant exists so data that does have it
/// can say so.
ClassifiedTrades,
}
impl fmt::Display for DeltaProvenance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::BarShape => "bar shape",
Self::IntrabarShape => "intrabar shape",
Self::ClassifiedTrades => "classified trades",
})
}
}
/// How an intrabar whose close did not move is booked.
///
/// A child bar that closes where it opened — or where the previous child closed — carries volume
/// that this construction cannot attribute. Guessing a side would manufacture exactly the
/// information the measurement is missing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum UnchangedIntrabarPolicy {
/// Its volume contributes nothing to the delta, and is counted as unattributed. The default.
#[default]
Unattributed,
/// Its volume keeps the direction of the previous attributed child, or is unattributed if
/// there is none. A deliberate assumption of continuation, not a measurement.
CarryPreviousDirection,
}
/// Cumulative volume delta of one anchor period, built from lower-timeframe bars.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct IntrabarDelta {
/// Parent bucket this belongs to.
pub timestamp: i64,
/// Buying minus selling volume of this parent bar alone.
pub delta: f64,
/// Volume booked as buying.
pub buy_volume: f64,
/// Volume booked as selling.
pub sell_volume: f64,
/// Volume that could not be attributed to either side under the chosen policy. It is reported
/// rather than distributed, because distributing it would invent a side.
pub unattributed_volume: f64,
/// The cumulative delta at the parent's open, high, low and close — the four corners of the
/// running total *within the anchor period*. Together they say whether the total moved
/// straight or swung on its way.
pub cumulative_open: f64,
pub cumulative_high: f64,
pub cumulative_low: f64,
pub cumulative_close: f64,
/// How many child bars this parent was built from. A parent with one child is the parent bar
/// itself and carries no intrabar information.
pub children: usize,
/// Always [`DeltaProvenance::IntrabarShape`] here. Carried so a consumer that mixes sources
/// keeps them apart without tracking which call produced which row.
pub provenance: DeltaProvenance,
}
/// When the cumulative delta returns to zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(
feature = "serde",
derive(Serialize, Deserialize),
serde(rename_all = "snake_case")
)]
pub enum DeltaAnchor {
/// Never: one running total across the whole series. The default.
#[default]
Continuous,
/// At the start of every UTC day, shifted by the given number of seconds.
Daily { start_offset_seconds: i64 },
}
/// Cumulative volume delta from lower-timeframe bars.
///
/// Each child bar is booked to one side by comparing its close to the previous child's close —
/// within a parent bar this is a sequence, so the direction of each step is what the data
/// supports. The first child of a series has no predecessor and is compared to its own open
/// instead.
///
/// What this construction cannot do is measure the aggressor. A child bar is an aggregate like
/// any other; its direction is inferred. The result is therefore
/// [`DeltaProvenance::IntrabarShape`], never [`DeltaProvenance::ClassifiedTrades`], and volume
/// that no rule attributes is reported as unattributed rather than split.
///
/// [`super::volume_flow::CvdEngine`] is untouched by this and remains the default for callers who
/// only have parent bars.
#[derive(Debug, Clone)]
pub struct IntrabarCvd {
anchor: DeltaAnchor,
unchanged_policy: UnchangedIntrabarPolicy,
cumulative: f64,
previous_close: Option<f64>,
previous_direction: f64,
current_day: Option<i64>,
}
impl IntrabarCvd {
pub fn new(anchor: DeltaAnchor, unchanged_policy: UnchangedIntrabarPolicy) -> Self {
Self {
anchor,
unchanged_policy,
cumulative: 0.0,
previous_close: None,
previous_direction: 0.0,
current_day: None,
}
}
pub fn with_defaults() -> Self {
Self::new(DeltaAnchor::Continuous, UnchangedIntrabarPolicy::default())
}
/// The running cumulative delta, as it stands.
pub fn cumulative(&self) -> f64 {
self.cumulative
}
fn anchor_period(&self, timestamp: i64) -> Option<i64> {
match self.anchor {
DeltaAnchor::Continuous => None,
DeltaAnchor::Daily {
start_offset_seconds,
} => Some((timestamp - start_offset_seconds).div_euclid(86_400)),
}
}
/// Books one parent bucket's children and returns its delta together with the four corners of
/// the cumulative total.
pub fn on_group(&mut self, group: &IntrabarGroup) -> IntrabarDelta {
if let Some(period) = self.anchor_period(group.parent_timestamp) {
if self.current_day != Some(period) {
self.current_day = Some(period);
self.cumulative = 0.0;
self.previous_close = None;
self.previous_direction = 0.0;
}
}
let cumulative_open = self.cumulative;
let (mut high, mut low) = (self.cumulative, self.cumulative);
let (mut buy, mut sell, mut unattributed) = (0.0, 0.0, 0.0);
for child in &group.children {
let reference = self.previous_close.unwrap_or(child.open);
let direction = if child.close > reference {
1.0
} else if child.close < reference {
-1.0
} else {
match self.unchanged_policy {
UnchangedIntrabarPolicy::Unattributed => 0.0,
UnchangedIntrabarPolicy::CarryPreviousDirection => self.previous_direction,
}
};
self.previous_close = Some(child.close);
if direction != 0.0 {
self.previous_direction = direction;
}
let volume = child.volume.max(0.0);
if direction > 0.0 {
buy += volume;
} else if direction < 0.0 {
sell += volume;
} else {
unattributed += volume;
}
self.cumulative += direction * volume;
high = high.max(self.cumulative);
low = low.min(self.cumulative);
}
IntrabarDelta {
timestamp: group.parent_timestamp,
delta: buy - sell,
buy_volume: buy,
sell_volume: sell,
unattributed_volume: unattributed,
cumulative_open,
cumulative_high: high,
cumulative_low: low,
cumulative_close: self.cumulative,
children: group.children.len(),
provenance: DeltaProvenance::IntrabarShape,
}
}
/// Books a parent bar for which no children are available.
///
/// The bar is *not* treated as one intrabar of its own: doing so would present an estimate
/// from the parent's shape as if it had come from the finer data. The volume is reported as
/// unattributed, the cumulative total does not move, and `children` is zero — which is how a
/// consumer sees that this parent contributed nothing measurable.
pub fn on_missing_children(&mut self, parent: &Bar) -> IntrabarDelta {
if let Some(period) = self.anchor_period(parent.timestamp) {
if self.current_day != Some(period) {
self.current_day = Some(period);
self.cumulative = 0.0;
self.previous_close = None;
self.previous_direction = 0.0;
}
}
IntrabarDelta {
timestamp: parent.timestamp,
delta: 0.0,
buy_volume: 0.0,
sell_volume: 0.0,
unattributed_volume: parent.volume.max(0.0),
cumulative_open: self.cumulative,
cumulative_high: self.cumulative,
cumulative_low: self.cumulative,
cumulative_close: self.cumulative,
children: 0,
provenance: DeltaProvenance::IntrabarShape,
}
}
pub fn reset(&mut self) {
self.cumulative = 0.0;
self.previous_close = None;
self.previous_direction = 0.0;
self.current_day = None;
}
}