kestrel_chartkit/indicator/rvat.rs
1use std::collections::{HashMap, VecDeque};
2
3use crate::model::Bar;
4
5use super::{Indicator, IndicatorOutput};
6
7const SECONDS_PER_DAY: i64 = 86_400;
8
9/// Relative Volume at Time: this bar's volume against the volume at the *same time of day* on
10/// previous days.
11///
12/// The rolling [`super::volume_indicators::RvolEngine`] answers a different question — how this
13/// bar compares to the recent ones, whatever hour they fell in. At 09:31 that comparison is
14/// dominated by the opening bar and says little. Here the comparison runs down the same column of
15/// the clock instead: 09:31 today against 09:31 on each of the previous `days` days.
16///
17/// Two ratios come out of it, and they are not interchangeable:
18/// - `value`: **regular** — this bar's volume divided by the average volume in the same slot on
19/// previous days.
20/// - `extra["cumulative"]`: **cumulative** — the volume accumulated since the start of today
21/// divided by the average of what had accumulated by the same slot on previous days. This is
22/// the one that answers "is today busy so far", and it is far less jumpy than the regular one.
23///
24/// The day starts at `day_start_offset` seconds after midnight UTC, so a market whose session
25/// does not align with UTC midnight can be placed correctly. A slot is the number of seconds into
26/// that day, which makes bars of any spacing line up as long as they arrive at consistent times.
27///
28/// **Only days strictly before today count as comparison.** Today's own bars never enter their
29/// own reference, so no value depends on data that did not exist when the bar closed.
30///
31/// How many comparison days actually contributed is published rather than assumed:
32/// - `extra["samples"]`: number of previous days that had a bar in this slot.
33/// - `extra["complete"]`: `1.0` when that number equals `days`, `0.0` otherwise.
34///
35/// A missing slot lowers `samples`; it is never filled in from a neighbouring slot or an earlier
36/// day. A shortened trading day therefore shows up as an incomplete comparison instead of a
37/// quietly wrong ratio.
38///
39/// Unit: a ratio, where `1.0` means "as much as usual at this time". Zero average volume in the
40/// comparison slots produces no output — a ratio against nothing has no meaning.
41///
42/// First output: the first bar whose slot has at least one previous day to compare against —
43/// one day of bars. How many bars that is depends on the timeframe, which this indicator cannot
44/// know from a timestamp alone, so `bar_seconds` states it. That parameter is *declarative only*:
45/// it makes [`Indicator::warmup_period`] an honest number of bars and has no influence on the
46/// calculation, which always derives slots from timestamps. A wrong value misstates the declared
47/// warmup, never a ratio.
48///
49/// Memory is bounded by pruning slots untouched for more than `days` days.
50#[derive(Debug, Clone)]
51pub struct RelativeVolumeAtTime {
52 days: usize,
53 day_start_offset: i64,
54 bar_seconds: i64,
55 /// Per slot, the most recent days' `(day index, bar volume, cumulative volume by that slot)`.
56 history: HashMap<i64, VecDeque<(i64, f64, f64)>>,
57 current_day: Option<i64>,
58 cumulative_today: f64,
59}
60
61impl RelativeVolumeAtTime {
62 pub fn new(days: usize, day_start_offset: i64, bar_seconds: i64) -> Self {
63 Self {
64 days: days.max(1),
65 day_start_offset,
66 bar_seconds: bar_seconds.clamp(1, SECONDS_PER_DAY),
67 history: HashMap::new(),
68 current_day: None,
69 cumulative_today: 0.0,
70 }
71 }
72
73 pub fn with_defaults() -> Self {
74 Self::new(10, 0, 60)
75 }
76
77 fn day_and_slot(&self, timestamp: i64) -> (i64, i64) {
78 let shifted = timestamp - self.day_start_offset;
79 (
80 shifted.div_euclid(SECONDS_PER_DAY),
81 shifted.rem_euclid(SECONDS_PER_DAY),
82 )
83 }
84
85 /// Drops slots whose newest entry is older than the comparison window; without this the map
86 /// would keep one entry per distinct time of day ever seen.
87 fn prune(&mut self, current_day: i64) {
88 let horizon = current_day - self.days as i64;
89 self.history.retain(|_, entries| {
90 entries.retain(|(day, _, _)| *day >= horizon);
91 !entries.is_empty()
92 });
93 }
94}
95
96impl Indicator for RelativeVolumeAtTime {
97 fn name(&self) -> &str {
98 "rvat"
99 }
100
101 fn warmup_period(&self) -> usize {
102 // One day of bars: a slot cannot repeat sooner than that. Expressed in bars via the
103 // declared `bar_seconds`, since the trait speaks in bars and this indicator thinks in
104 // days.
105 (SECONDS_PER_DAY / self.bar_seconds) as usize
106 }
107
108 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
109 if !bar.volume.is_finite() || bar.volume < 0.0 {
110 return None;
111 }
112
113 let (day, slot) = self.day_and_slot(bar.timestamp);
114 if self.current_day != Some(day) {
115 self.current_day = Some(day);
116 self.cumulative_today = 0.0;
117 self.prune(day);
118 }
119 self.cumulative_today += bar.volume;
120
121 let entries = self.history.entry(slot).or_default();
122 // Only previous days are comparison material; today's own entry is appended afterwards.
123 let previous: Vec<(f64, f64)> = entries
124 .iter()
125 .filter(|(entry_day, _, _)| *entry_day < day)
126 .map(|(_, volume, cumulative)| (*volume, *cumulative))
127 .collect();
128
129 entries.push_back((day, bar.volume, self.cumulative_today));
130 while entries.len() > self.days + 1 {
131 entries.pop_front();
132 }
133
134 let samples = previous.len();
135 if samples == 0 {
136 return None;
137 }
138
139 let average_volume = previous.iter().map(|(v, _)| v).sum::<f64>() / samples as f64;
140 let average_cumulative = previous.iter().map(|(_, c)| c).sum::<f64>() / samples as f64;
141 if average_volume <= 0.0 || average_cumulative <= 0.0 {
142 return None;
143 }
144
145 let mut extra = HashMap::new();
146 extra.insert(
147 "cumulative".to_string(),
148 self.cumulative_today / average_cumulative,
149 );
150 extra.insert("samples".to_string(), samples as f64);
151 extra.insert(
152 "complete".to_string(),
153 if samples >= self.days { 1.0 } else { 0.0 },
154 );
155
156 Some(IndicatorOutput::with_extra(
157 bar.volume / average_volume,
158 extra,
159 ))
160 }
161
162 fn reset(&mut self) {
163 self.history.clear();
164 self.current_day = None;
165 self.cumulative_today = 0.0;
166 }
167}