Skip to main content

kestrel_chartkit/analytics/
cheat_sheet.rs

1//! Price levels from an OHLC window: support/resistance, pivots, retracements and RSI targets.
2//! Pivots use the supplied window, not an implicitly selected exchange session.
3
4#[cfg(feature = "serde")]
5use serde::Serialize;
6
7use crate::Bar;
8
9const DEFAULT_WINDOW: usize = 120;
10const RSI_LEN: usize = 14;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(Serialize))]
14#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
15pub enum CheatSheetLevelKind {
16    Support,
17    Resistance,
18    Pivot,
19    Fibonacci,
20    MovingAverage,
21    RsiTarget,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[cfg_attr(feature = "serde", derive(Serialize))]
26#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
27pub enum CheatSheetLevelSide {
28    Below,
29    At,
30    Above,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34#[cfg_attr(feature = "serde", derive(Serialize))]
35pub struct CheatSheetLevel {
36    pub kind: CheatSheetLevelKind,
37    pub label: String,
38    pub price: f64,
39    pub distance_pct: f64,
40    pub side: CheatSheetLevelSide,
41    pub strength: f64,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45#[cfg_attr(feature = "serde", derive(Serialize))]
46pub struct CheatSheetReading {
47    pub last: f64,
48    pub window_high: f64,
49    pub window_low: f64,
50    pub levels: Vec<CheatSheetLevel>,
51}
52
53/// Price levels around the last close, from the last `window` bars (120 for 0, at least 2, at
54/// most all). `None` for fewer than 2 bars, a window without range, or a non-positive last close.
55///
56/// Each level carries `distance_pct = 100 · (price / last - 1)`, a side (above beyond +0.05 %,
57/// below beyond -0.05 %, else at) and a strength clamped to `0..=1`; levels with a non-finite or
58/// non-positive price are dropped. `H`/`L` are the window's highest high and lowest low.
59///
60/// - `S1`..`S3`, `R1`..`R3`: swing lows (a low at or below the two lows on either side) and the
61///   window's lowest low as supports at or below the close, swing highs and the highest high as
62///   resistances at or above it; nearest first, a level within the tolerance
63///   `max(1.5 % of (H - L), 0.1 % of last)` of a nearer one dropped. Strength: touches / 6, the
64///   touches being the bars with a high or low within the tolerance (1 for the window extremes).
65/// - Pivots with `P = (H + L + last) / 3`: `Pivot` P (0.8), `Pivot S1` 2P - H and `Pivot R1`
66///   2P - L (0.65), `Pivot S2` P - (H - L) and `Pivot R2` P + (H - L) (0.45).
67/// - `Fib x%`: `H - (H - L) · x` for 23.6, 38.2, 50, 61.8 and 78.6 % (0.5).
68/// - `SMA20`, `SMA50`, `SMA200` of the closes when the window holds that many bars (0.55), and
69///   `SMA20/50 Stall` at the mean of the first two when they lie within
70///   `max(2.5 % of (H - L), 0.25 % of last)` of each other (0.8).
71/// - `RSI 30`, `RSI 50`, `RSI 70`: the next close that brings Wilder's RSI(14) over the window's
72///   closes to that level in one bar (0.5). With `RS = t / (100 - t)` and the averages after the
73///   last close, the close changes by `13 · (RS · avg_loss - avg_gain)` when that is not
74///   negative (a rise), otherwise by `13 · (avg_loss - avg_gain / RS)` (a fall); kept only when
75///   the resulting price is positive.
76///
77/// Sorted by absolute distance, then label.
78pub fn cheat_sheet(bars: &[Bar], window: usize) -> Option<CheatSheetReading> {
79    if bars.len() < 2 {
80        return None;
81    }
82    let requested = if window == 0 { DEFAULT_WINDOW } else { window };
83    let n = requested.max(2).min(bars.len());
84    let bars = &bars[bars.len() - n..];
85    let last = bars.last()?.close;
86    let window_high = bars
87        .iter()
88        .map(|b| b.high)
89        .fold(f64::NEG_INFINITY, f64::max);
90    let window_low = bars.iter().map(|b| b.low).fold(f64::INFINITY, f64::min);
91    let span = window_high - window_low;
92    if span <= 0.0 || last <= 0.0 {
93        return None;
94    }
95
96    let mut levels = Vec::new();
97    add_support_resistance(&mut levels, bars, last, span);
98    add_pivots(&mut levels, last, window_high, window_low);
99    add_fibonacci(&mut levels, last, window_high, window_low);
100    add_moving_averages(&mut levels, bars, last, span);
101    add_rsi_targets(&mut levels, bars, last);
102
103    levels.sort_by(|a, b| {
104        a.distance_pct
105            .abs()
106            .total_cmp(&b.distance_pct.abs())
107            .then_with(|| a.label.cmp(&b.label))
108    });
109
110    Some(CheatSheetReading {
111        last,
112        window_high,
113        window_low,
114        levels,
115    })
116}
117
118fn add_level(
119    levels: &mut Vec<CheatSheetLevel>,
120    kind: CheatSheetLevelKind,
121    label: impl Into<String>,
122    price: f64,
123    last: f64,
124    strength: f64,
125) {
126    if !price.is_finite() || price <= 0.0 || last <= 0.0 {
127        return;
128    }
129    let distance_pct = 100.0 * (price / last - 1.0);
130    let side = if distance_pct > 0.05 {
131        CheatSheetLevelSide::Above
132    } else if distance_pct < -0.05 {
133        CheatSheetLevelSide::Below
134    } else {
135        CheatSheetLevelSide::At
136    };
137    levels.push(CheatSheetLevel {
138        kind,
139        label: label.into(),
140        price,
141        distance_pct,
142        side,
143        strength: strength.clamp(0.0, 1.0),
144    });
145}
146
147fn add_support_resistance(levels: &mut Vec<CheatSheetLevel>, bars: &[Bar], last: f64, span: f64) {
148    let tolerance = (span * 0.015).max(last * 0.001);
149    let mut supports = Vec::new();
150    let mut resistances = Vec::new();
151
152    for i in 2..bars.len().saturating_sub(2) {
153        let low = bars[i].low;
154        if low <= bars[i - 1].low
155            && low <= bars[i - 2].low
156            && low <= bars[i + 1].low
157            && low <= bars[i + 2].low
158        {
159            supports.push((low, touches(bars, low, tolerance)));
160        }
161        let high = bars[i].high;
162        if high >= bars[i - 1].high
163            && high >= bars[i - 2].high
164            && high >= bars[i + 1].high
165            && high >= bars[i + 2].high
166        {
167            resistances.push((high, touches(bars, high, tolerance)));
168        }
169    }
170
171    supports.push((bars.iter().map(|b| b.low).fold(f64::INFINITY, f64::min), 1));
172    resistances.push((
173        bars.iter()
174            .map(|b| b.high)
175            .fold(f64::NEG_INFINITY, f64::max),
176        1,
177    ));
178
179    supports.sort_by(|a, b| (last - b.0).abs().total_cmp(&(last - a.0).abs()));
180    supports.retain(|(price, _)| *price <= last);
181    supports.sort_by(|a, b| (last - a.0).total_cmp(&(last - b.0)));
182    supports.dedup_by(|a, b| (a.0 - b.0).abs() <= tolerance);
183
184    resistances.retain(|(price, _)| *price >= last);
185    resistances.sort_by(|a, b| (a.0 - last).total_cmp(&(b.0 - last)));
186    resistances.dedup_by(|a, b| (a.0 - b.0).abs() <= tolerance);
187
188    for (idx, (price, count)) in supports.into_iter().take(3).enumerate() {
189        add_level(
190            levels,
191            CheatSheetLevelKind::Support,
192            format!("S{}", idx + 1),
193            price,
194            last,
195            count as f64 / 6.0,
196        );
197    }
198    for (idx, (price, count)) in resistances.into_iter().take(3).enumerate() {
199        add_level(
200            levels,
201            CheatSheetLevelKind::Resistance,
202            format!("R{}", idx + 1),
203            price,
204            last,
205            count as f64 / 6.0,
206        );
207    }
208}
209
210fn touches(bars: &[Bar], level: f64, tolerance: f64) -> usize {
211    bars.iter()
212        .filter(|b| (b.high - level).abs() <= tolerance || (b.low - level).abs() <= tolerance)
213        .count()
214}
215
216fn add_pivots(levels: &mut Vec<CheatSheetLevel>, last: f64, high: f64, low: f64) {
217    let pivot = (high + low + last) / 3.0;
218    add_level(
219        levels,
220        CheatSheetLevelKind::Pivot,
221        "Pivot",
222        pivot,
223        last,
224        0.8,
225    );
226    add_level(
227        levels,
228        CheatSheetLevelKind::Pivot,
229        "Pivot S1",
230        2.0 * pivot - high,
231        last,
232        0.65,
233    );
234    add_level(
235        levels,
236        CheatSheetLevelKind::Pivot,
237        "Pivot R1",
238        2.0 * pivot - low,
239        last,
240        0.65,
241    );
242    add_level(
243        levels,
244        CheatSheetLevelKind::Pivot,
245        "Pivot S2",
246        pivot - (high - low),
247        last,
248        0.45,
249    );
250    add_level(
251        levels,
252        CheatSheetLevelKind::Pivot,
253        "Pivot R2",
254        pivot + (high - low),
255        last,
256        0.45,
257    );
258}
259
260fn add_fibonacci(levels: &mut Vec<CheatSheetLevel>, last: f64, high: f64, low: f64) {
261    for ratio in [0.236, 0.382, 0.5, 0.618, 0.786] {
262        let price = high - (high - low) * ratio;
263        add_level(
264            levels,
265            CheatSheetLevelKind::Fibonacci,
266            format!("Fib {:.1}%", ratio * 100.0),
267            price,
268            last,
269            0.5,
270        );
271    }
272}
273
274fn add_moving_averages(levels: &mut Vec<CheatSheetLevel>, bars: &[Bar], last: f64, span: f64) {
275    let ma20 = sma(bars, 20);
276    let ma50 = sma(bars, 50);
277    let ma200 = sma(bars, 200);
278    for (label, value) in [("SMA20", ma20), ("SMA50", ma50), ("SMA200", ma200)] {
279        if let Some(price) = value {
280            add_level(
281                levels,
282                CheatSheetLevelKind::MovingAverage,
283                label,
284                price,
285                last,
286                0.55,
287            );
288        }
289    }
290    if let (Some(a), Some(b)) = (ma20, ma50) {
291        let spread = (a - b).abs();
292        if spread <= (span * 0.025).max(last * 0.0025) {
293            add_level(
294                levels,
295                CheatSheetLevelKind::MovingAverage,
296                "SMA20/50 Stall",
297                (a + b) / 2.0,
298                last,
299                0.8,
300            );
301        }
302    }
303}
304
305fn sma(bars: &[Bar], len: usize) -> Option<f64> {
306    if bars.len() < len {
307        return None;
308    }
309    let window = &bars[bars.len() - len..];
310    Some(window.iter().map(|b| b.close).sum::<f64>() / len as f64)
311}
312
313fn add_rsi_targets(levels: &mut Vec<CheatSheetLevel>, bars: &[Bar], last: f64) {
314    let Some((avg_gain, avg_loss)) = wilder_gain_loss(bars, RSI_LEN) else {
315        return;
316    };
317    for target in [30.0, 50.0, 70.0] {
318        if let Some(price) = rsi_target_price(last, avg_gain, avg_loss, RSI_LEN, target) {
319            add_level(
320                levels,
321                CheatSheetLevelKind::RsiTarget,
322                format!("RSI {:.0}", target),
323                price,
324                last,
325                0.5,
326            );
327        }
328    }
329}
330
331fn wilder_gain_loss(bars: &[Bar], len: usize) -> Option<(f64, f64)> {
332    if bars.len() < len + 1 {
333        return None;
334    }
335    let mut gains = Vec::with_capacity(len);
336    let mut losses = Vec::with_capacity(len);
337    for pair in bars[..=len].windows(2) {
338        let change = pair[1].close - pair[0].close;
339        gains.push(change.max(0.0));
340        losses.push((-change).max(0.0));
341    }
342    let mut avg_gain = gains.iter().sum::<f64>() / len as f64;
343    let mut avg_loss = losses.iter().sum::<f64>() / len as f64;
344    for pair in bars[len..].windows(2) {
345        let change = pair[1].close - pair[0].close;
346        avg_gain = (avg_gain * (len as f64 - 1.0) + change.max(0.0)) / len as f64;
347        avg_loss = (avg_loss * (len as f64 - 1.0) + (-change).max(0.0)) / len as f64;
348    }
349    Some((avg_gain, avg_loss))
350}
351
352fn rsi_target_price(
353    last: f64,
354    avg_gain: f64,
355    avg_loss: f64,
356    len: usize,
357    target: f64,
358) -> Option<f64> {
359    if !(0.0..100.0).contains(&target) {
360        return None;
361    }
362    let rs = target / (100.0 - target);
363    let k = len as f64 - 1.0;
364    let up_delta = rs * avg_loss * k - avg_gain * k;
365    let down_delta = avg_loss * k - avg_gain * k / rs;
366    let delta = if up_delta >= 0.0 {
367        up_delta
368    } else if down_delta <= 0.0 {
369        down_delta
370    } else {
371        up_delta
372    };
373    let price = last + delta;
374    (price > 0.0).then_some(price)
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn bar(c: f64) -> Bar {
382        Bar {
383            timestamp: 0,
384            open: c,
385            high: c + 1.0,
386            low: c - 1.0,
387            close: c,
388            volume: 1.0,
389        }
390    }
391
392    #[test]
393    fn creates_core_level_groups() {
394        let bars: Vec<Bar> = (0..140).map(|i| bar(100.0 + (i % 20) as f64)).collect();
395        let r = cheat_sheet(&bars, 120).expect("reading");
396        assert!(r
397            .levels
398            .iter()
399            .any(|l| l.kind == CheatSheetLevelKind::Pivot));
400        assert!(r
401            .levels
402            .iter()
403            .any(|l| l.kind == CheatSheetLevelKind::Fibonacci));
404        assert!(r
405            .levels
406            .iter()
407            .any(|l| l.kind == CheatSheetLevelKind::MovingAverage));
408        assert!(r
409            .levels
410            .iter()
411            .any(|l| l.kind == CheatSheetLevelKind::RsiTarget));
412    }
413}