Skip to main content

kestrel_chartkit/analytics/
price.rs

1//! Window price summary and Wilder ATR history, in price units.
2
3#[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    /// Highest high / lowest low across the supplied window (the caller picks
17    /// the window length — e.g. one session's worth of bars).
18    pub window_high: f64,
19    pub window_low: f64,
20    /// Where `last` sits in `[window_low, window_high]`, 0..1 (0.5 if the
21    /// window is flat).
22    pub range_position: f64,
23    /// Wilder ATR in price units, and as a percentage of `last`.
24    pub atr: f64,
25    pub atr_pct: f64,
26    /// Fraction of the ATR history (0..1) at or below the current ATR — a
27    /// cheap "is volatility unusually high/low right now" read.
28    pub atr_percentile: f64,
29    /// Suggested stop distance in price units (`stop_mult · atr`).
30    pub stop_distance: f64,
31}
32
33/// Builds the summary from `bars` (oldest first). `atr_len` is the Wilder ATR
34/// period, `stop_mult` the ATR multiple for the suggested stop. `None` for
35/// fewer than `atr_len + 1` bars.
36///
37/// `change_pct = 100 · (last - prev_close) / prev_close` (0 for a zero previous close); the
38/// window extremes span every supplied bar. The ATR is Wilder's average of the true range (the
39/// first bar's `high - low`), seeded with the mean of the first `atr_len` values and published
40/// from bar `atr_len` on; `atr_percentile` is the share of that published series, the current
41/// value included, at or below the current value; `atr_pct = 100 · atr / last` (0 for a zero
42/// last close).
43pub 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
89/// Wilder ATR (RMA of true range) as a series, one value per bar once the
90/// seed window has filled, in absolute price units; the whole series is kept
91/// so `atr_percentile` has something to rank against.
92fn 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
104/// Fraction of `series` values ≤ its last element, 0..1.
105fn 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        // 20 flat bars then a step up, so last sits at the top of the range.
131        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}