1#[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
53pub 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}