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
use std::collections::{HashMap, VecDeque};
use crate::model::Bar;
use super::{Indicator, IndicatorOutput};
const SECONDS_PER_DAY: i64 = 86_400;
/// Relative Volume at Time: this bar's volume against the volume at the *same time of day* on
/// previous days.
///
/// The rolling [`super::volume_indicators::RvolEngine`] answers a different question — how this
/// bar compares to the recent ones, whatever hour they fell in. At 09:31 that comparison is
/// dominated by the opening bar and says little. Here the comparison runs down the same column of
/// the clock instead: 09:31 today against 09:31 on each of the previous `days` days.
///
/// Two ratios come out of it, and they are not interchangeable:
/// - `value`: **regular** — this bar's volume divided by the average volume in the same slot on
/// previous days.
/// - `extra["cumulative"]`: **cumulative** — the volume accumulated since the start of today
/// divided by the average of what had accumulated by the same slot on previous days. This is
/// the one that answers "is today busy so far", and it is far less jumpy than the regular one.
///
/// The day starts at `day_start_offset` seconds after midnight UTC, so a market whose session
/// does not align with UTC midnight can be placed correctly. A slot is the number of seconds into
/// that day, which makes bars of any spacing line up as long as they arrive at consistent times.
///
/// **Only days strictly before today count as comparison.** Today's own bars never enter their
/// own reference, so no value depends on data that did not exist when the bar closed.
///
/// How many comparison days actually contributed is published rather than assumed:
/// - `extra["samples"]`: number of previous days that had a bar in this slot.
/// - `extra["complete"]`: `1.0` when that number equals `days`, `0.0` otherwise.
///
/// A missing slot lowers `samples`; it is never filled in from a neighbouring slot or an earlier
/// day. A shortened trading day therefore shows up as an incomplete comparison instead of a
/// quietly wrong ratio.
///
/// Unit: a ratio, where `1.0` means "as much as usual at this time". Zero average volume in the
/// comparison slots produces no output — a ratio against nothing has no meaning.
///
/// First output: the first bar whose slot has at least one previous day to compare against —
/// one day of bars. How many bars that is depends on the timeframe, which this indicator cannot
/// know from a timestamp alone, so `bar_seconds` states it. That parameter is *declarative only*:
/// it makes [`Indicator::warmup_period`] an honest number of bars and has no influence on the
/// calculation, which always derives slots from timestamps. A wrong value misstates the declared
/// warmup, never a ratio.
///
/// Memory is bounded by pruning slots untouched for more than `days` days.
#[derive(Debug, Clone)]
pub struct RelativeVolumeAtTime {
days: usize,
day_start_offset: i64,
bar_seconds: i64,
/// Per slot, the most recent days' `(day index, bar volume, cumulative volume by that slot)`.
history: HashMap<i64, VecDeque<(i64, f64, f64)>>,
current_day: Option<i64>,
cumulative_today: f64,
}
impl RelativeVolumeAtTime {
pub fn new(days: usize, day_start_offset: i64, bar_seconds: i64) -> Self {
Self {
days: days.max(1),
day_start_offset,
bar_seconds: bar_seconds.clamp(1, SECONDS_PER_DAY),
history: HashMap::new(),
current_day: None,
cumulative_today: 0.0,
}
}
pub fn with_defaults() -> Self {
Self::new(10, 0, 60)
}
fn day_and_slot(&self, timestamp: i64) -> (i64, i64) {
let shifted = timestamp - self.day_start_offset;
(
shifted.div_euclid(SECONDS_PER_DAY),
shifted.rem_euclid(SECONDS_PER_DAY),
)
}
/// Drops slots whose newest entry is older than the comparison window; without this the map
/// would keep one entry per distinct time of day ever seen.
fn prune(&mut self, current_day: i64) {
let horizon = current_day - self.days as i64;
self.history.retain(|_, entries| {
entries.retain(|(day, _, _)| *day >= horizon);
!entries.is_empty()
});
}
}
impl Indicator for RelativeVolumeAtTime {
fn name(&self) -> &str {
"rvat"
}
fn warmup_period(&self) -> usize {
// One day of bars: a slot cannot repeat sooner than that. Expressed in bars via the
// declared `bar_seconds`, since the trait speaks in bars and this indicator thinks in
// days.
(SECONDS_PER_DAY / self.bar_seconds) as usize
}
fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
if !bar.volume.is_finite() || bar.volume < 0.0 {
return None;
}
let (day, slot) = self.day_and_slot(bar.timestamp);
if self.current_day != Some(day) {
self.current_day = Some(day);
self.cumulative_today = 0.0;
self.prune(day);
}
self.cumulative_today += bar.volume;
let entries = self.history.entry(slot).or_default();
// Only previous days are comparison material; today's own entry is appended afterwards.
let previous: Vec<(f64, f64)> = entries
.iter()
.filter(|(entry_day, _, _)| *entry_day < day)
.map(|(_, volume, cumulative)| (*volume, *cumulative))
.collect();
entries.push_back((day, bar.volume, self.cumulative_today));
while entries.len() > self.days + 1 {
entries.pop_front();
}
let samples = previous.len();
if samples == 0 {
return None;
}
let average_volume = previous.iter().map(|(v, _)| v).sum::<f64>() / samples as f64;
let average_cumulative = previous.iter().map(|(_, c)| c).sum::<f64>() / samples as f64;
if average_volume <= 0.0 || average_cumulative <= 0.0 {
return None;
}
let mut extra = HashMap::new();
extra.insert(
"cumulative".to_string(),
self.cumulative_today / average_cumulative,
);
extra.insert("samples".to_string(), samples as f64);
extra.insert(
"complete".to_string(),
if samples >= self.days { 1.0 } else { 0.0 },
);
Some(IndicatorOutput::with_extra(
bar.volume / average_volume,
extra,
))
}
fn reset(&mut self) {
self.history.clear();
self.current_day = None;
self.cumulative_today = 0.0;
}
}