Skip to main content

kestrel_chartkit/analytics/
activity.rs

1//! Activity from ATR and optional volume percentiles. The caller supplies volume applicability.
2
3#[cfg(feature = "serde")]
4use serde::Serialize;
5
6use crate::Bar;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9#[cfg_attr(feature = "serde", derive(Serialize))]
10pub struct ActivityReading {
11    pub atr_percentile: f64,
12    /// `None` when the volume component is disabled or the window has no
13    /// usable (non-zero) volume.
14    pub volume_percentile: Option<f64>,
15    /// Composite 0..1 — mean of whichever components are present.
16    pub score: f64,
17}
18
19/// `atr_percentile` is the caller's already-computed
20/// `PriceSummary::atr_percentile`. `use_volume` toggles the volume
21/// component; `window` bounds how far back "recent history" reaches for the
22/// volume percentile.
23///
24/// `volume_percentile` is the share of the last `window` bars (at least one) whose volume is at
25/// or below the last bar's, `None` when disabled or when every volume in that window is zero;
26/// `score` is the mean of `atr_percentile` and `volume_percentile`, or `atr_percentile` alone.
27pub fn activity_reading(
28    bars: &[Bar],
29    atr_percentile: f64,
30    use_volume: bool,
31    window: usize,
32) -> ActivityReading {
33    let volume_percentile = if use_volume {
34        volume_percentile_of_last(bars, window)
35    } else {
36        None
37    };
38
39    let score = match volume_percentile {
40        Some(v) => (atr_percentile + v) / 2.0,
41        None => atr_percentile,
42    };
43
44    ActivityReading {
45        atr_percentile,
46        volume_percentile,
47        score,
48    }
49}
50
51/// Fraction of the last `window` bars' volume that is ≤ the most recent
52/// bar's volume, 0..1. `None` if there's no bar, or every bar in the window
53/// has zero volume (no usable data, e.g. a feed that doesn't report it).
54fn volume_percentile_of_last(bars: &[Bar], window: usize) -> Option<f64> {
55    if bars.is_empty() {
56        return None;
57    }
58    let start = bars.len().saturating_sub(window.max(1));
59    let series = &bars[start..];
60    if series.iter().all(|b| b.volume == 0.0) {
61        return None;
62    }
63    let last = series.last()?.volume;
64    let below = series.iter().filter(|b| b.volume <= last).count();
65    Some(below as f64 / series.len() as f64)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    fn bar(volume: f64) -> Bar {
73        Bar {
74            timestamp: 0,
75            open: 100.0,
76            high: 100.5,
77            low: 99.5,
78            close: 100.0,
79            volume,
80        }
81    }
82
83    #[test]
84    fn volume_disabled_uses_atr_only() {
85        let bars: Vec<Bar> = (0..20).map(|i| bar(i as f64)).collect();
86        let r = activity_reading(&bars, 0.7, false, 60);
87        assert_eq!(r.volume_percentile, None);
88        assert_eq!(r.score, 0.7);
89    }
90
91    #[test]
92    fn highest_recent_volume_gives_percentile_one() {
93        let bars: Vec<Bar> = (0..20).map(|i| bar(i as f64)).collect();
94        let r = activity_reading(&bars, 0.5, true, 60);
95        assert_eq!(r.volume_percentile, Some(1.0));
96        assert!((r.score - 0.75).abs() < 1e-9);
97    }
98
99    #[test]
100    fn zero_volume_series_falls_back_to_atr_only() {
101        let bars: Vec<Bar> = (0..20).map(|_| bar(0.0)).collect();
102        let r = activity_reading(&bars, 0.5, true, 60);
103        assert_eq!(r.volume_percentile, None);
104        assert_eq!(r.score, 0.5);
105    }
106
107    #[test]
108    fn empty_bars_is_atr_only() {
109        let r = activity_reading(&[], 0.3, true, 60);
110        assert_eq!(r.volume_percentile, None);
111        assert_eq!(r.score, 0.3);
112    }
113}