kestrel_chartkit/analytics/
price.rs1#[cfg(feature = "serde")]
4use serde::Serialize;
5
6use super::true_range;
7use crate::Bar;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
10#[cfg_attr(feature = "serde", derive(Serialize))]
11pub struct PriceSummary {
12 pub last: f64,
13 pub prev_close: f64,
14 pub change_abs: f64,
15 pub change_pct: f64,
16 pub window_high: f64,
19 pub window_low: f64,
20 pub range_position: f64,
23 pub atr: f64,
25 pub atr_pct: f64,
26 pub atr_percentile: f64,
29 pub stop_distance: f64,
31}
32
33pub fn price_summary(bars: &[Bar], atr_len: usize, stop_mult: f64) -> Option<PriceSummary> {
44 if atr_len < 1 || bars.len() < atr_len + 1 {
45 return None;
46 }
47 let last = bars[bars.len() - 1].close;
48 let prev_close = bars[bars.len() - 2].close;
49 let change_abs = last - prev_close;
50 let change_pct = if prev_close != 0.0 {
51 100.0 * change_abs / prev_close
52 } else {
53 0.0
54 };
55
56 let window_high = bars
57 .iter()
58 .map(|b| b.high)
59 .fold(f64::NEG_INFINITY, f64::max);
60 let window_low = bars.iter().map(|b| b.low).fold(f64::INFINITY, f64::min);
61 let span = window_high - window_low;
62 let range_position = if span > 0.0 {
63 ((last - window_low) / span).clamp(0.0, 1.0)
64 } else {
65 0.5
66 };
67
68 let atr_series = wilder_atr_series(bars, atr_len);
69 let atr = *atr_series.last()?;
70 let atr_pct = if last != 0.0 { 100.0 * atr / last } else { 0.0 };
71 let atr_percentile = percentile_of_last(&atr_series);
72 let stop_distance = stop_mult * atr;
73
74 Some(PriceSummary {
75 last,
76 prev_close,
77 change_abs,
78 change_pct,
79 window_high,
80 window_low,
81 range_position,
82 atr,
83 atr_pct,
84 atr_percentile,
85 stop_distance,
86 })
87}
88
89fn wilder_atr_series(bars: &[Bar], len: usize) -> Vec<f64> {
93 let mut smoother = crate::indicator::smoothing::Rma::new(len);
94 let mut prev_close = None;
95 bars.iter()
96 .filter_map(|bar| {
97 let tr = true_range(bar, prev_close);
98 prev_close = Some(bar.close);
99 smoother.update(tr)
100 })
101 .collect()
102}
103
104fn percentile_of_last(series: &[f64]) -> f64 {
106 let Some(&last) = series.last() else {
107 return 0.0;
108 };
109 let below = series.iter().filter(|&&v| v <= last).count();
110 below as f64 / series.len() as f64
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 fn bar(h: f64, l: f64, c: f64) -> Bar {
118 Bar {
119 timestamp: 0,
120 open: c,
121 high: h,
122 low: l,
123 close: c,
124 volume: 0.0,
125 }
126 }
127
128 #[test]
129 fn change_and_range_position() {
130 let mut bars: Vec<Bar> = (0..20).map(|_| bar(100.5, 99.5, 100.0)).collect();
132 bars.push(bar(102.0, 101.0, 102.0));
133 let s = price_summary(&bars, 14, 1.5).expect("enough bars");
134 assert_eq!(s.last, 102.0);
135 assert_eq!(s.prev_close, 100.0);
136 assert!((s.change_abs - 2.0).abs() < 1e-9);
137 assert!((s.change_pct - 2.0).abs() < 1e-9);
138 assert_eq!(s.window_high, 102.0);
139 assert!(s.range_position > 0.99, "last is the window high");
140 assert!(s.stop_distance > 0.0);
141 assert!((0.0..=1.0).contains(&s.atr_percentile));
142 }
143
144 #[test]
145 fn too_few_bars_is_none() {
146 let bars: Vec<Bar> = (0..5).map(|_| bar(101.0, 99.0, 100.0)).collect();
147 assert!(price_summary(&bars, 14, 1.5).is_none());
148 }
149}