Skip to main content

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}