kestrel_chartkit/analytics/
activity.rs1#[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 pub volume_percentile: Option<f64>,
15 pub score: f64,
17}
18
19pub 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
51fn 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}