use gpui::SharedString; use super::{ candles::Candle, indicators::{Reading, bollinger, ema, kdj, macd, obv, rsi, sma, vwap}, }; /// A line drawn over the prices. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Overlay { /// A simple moving average of this many closes. Sma(usize), /// An exponential moving average of this many closes. Ema(usize), /// Bollinger bands over this many closes, this many deviations wide. Bollinger(usize, f64), /// The volume-weighted average price. Vwap, } /// A pane of its own under the prices. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Study { Macd, Rsi, Kdj, Obv, } /// What an overlay or a study draws: its name, its lines in palette colors, a band to shade, bars about zero, guide levels, and a fixed range when it has one. #[derive(Clone, Debug, Default)] pub(crate) struct Drawn { pub name: SharedString, pub lines: Vec<(usize, Reading)>, pub band: Option<(Reading, Reading)>, pub bars: Option, pub guides: Vec, pub range: Option<(f64, f64)>, } fn closes(candles: &[Candle]) -> Vec { candles.iter().map(|candle| candle.close).collect() } /// What an overlay draws for these candles; `color` is its place in the palette. pub(crate) fn overlay(overlay: Overlay, candles: &[Candle], color: usize) -> Drawn { let closes = closes(candles); match overlay { Overlay::Sma(period) => Drawn { name: format!("SMA {period}").into(), lines: vec![(color, sma(&closes, period))], ..Drawn::default() }, Overlay::Ema(period) => Drawn { name: format!("EMA {period}").into(), lines: vec![(color, ema(&closes, period))], ..Drawn::default() }, Overlay::Vwap => Drawn { name: "VWAP".into(), lines: vec![(color, vwap(candles))], ..Drawn::default() }, Overlay::Bollinger(period, width) => { let bands = bollinger(&closes, period, width); let part = |pick: fn((f64, f64, f64)) -> f64| { bands.iter().map(|band| band.map(pick)).collect::() }; Drawn { name: format!("BOLL {period} {width}").into(), lines: vec![(color, part(|band| band.1))], band: Some((part(|band| band.0), part(|band| band.2))), ..Drawn::default() } } } } /// What a study draws for these candles, in the palette from `color` on. pub(crate) fn study(study: Study, candles: &[Candle], color: usize) -> Drawn { let closes = closes(candles); match study { Study::Macd => { let trend = macd(&closes, (12, 26, 9)); Drawn { name: "MACD 12 26 9".into(), lines: vec![(color, trend.line), (color + 1, trend.signal)], bars: Some(trend.histogram), guides: vec![0.0], ..Drawn::default() } } Study::Rsi => Drawn { name: "RSI 14".into(), lines: vec![(color, rsi(&closes, 14))], guides: vec![30.0, 70.0], range: Some((0.0, 100.0)), ..Drawn::default() }, Study::Kdj => { let (k, d, j) = kdj(candles, 9); Drawn { name: "KDJ 9".into(), lines: vec![(color, k), (color + 1, d), (color + 2, j)], guides: vec![20.0, 80.0], ..Drawn::default() } } Study::Obv => Drawn { name: "OBV".into(), lines: vec![(color, obv(candles).into_iter().map(Some).collect())], ..Drawn::default() }, } } impl Drawn { /// The lowest and highest it reaches over `range` of candles, bars and bands included; its fixed range when it has one. pub(crate) fn reach(&self, range: std::ops::Range) -> Option<(f64, f64)> { if self.range.is_some() { return self.range; } let band = self.band.iter().flat_map(|(low, high)| [low, high]); let readings = self .lines .iter() .map(|(_, reading)| reading) .chain(self.bars.iter()) .chain(band); let values = readings.flat_map(|reading| reading[range.clone()].iter().flatten().copied()); let guides = self.guides.iter().copied().filter(|_| self.bars.is_some()); values.chain(guides).fold(None, |reach, value| { let (low, high) = reach.unwrap_or((value, value)); Some((low.min(value), high.max(value))) }) } } #[cfg(test)] mod tests { use jiff::Timestamp; use super::*; fn rising(count: usize) -> Vec { (0..count) .map(|ix| { let close = 100.0 + ix as f64; Candle::new( Timestamp::UNIX_EPOCH, (close - 0.5, close + 1.0, close - 1.0, close), 10.0, ) }) .collect() } #[test] fn overlays_name_themselves_and_reach_over_what_shows() { let candles = rising(30); let average = overlay(Overlay::Sma(5), &candles, 2); assert_eq!(average.name.as_ref(), "SMA 5"); assert_eq!(average.reach(0..4), None, "nothing to average yet"); assert_eq!(average.reach(4..6), Some((102.0, 103.0))); let bands = overlay(Overlay::Bollinger(20, 2.0), &candles, 0); let (low, high) = bands.reach(19..30).expect("bands"); assert!( low < 110.0 && high > 128.0, "the bands spread past the average" ); } #[test] fn studies_keep_their_ranges_and_guides() { let candles = rising(40); let strength = study(Study::Rsi, &candles, 0); assert_eq!( strength.reach(0..40), Some((0.0, 100.0)), "RSI always reads out of a hundred" ); let trend = study(Study::Macd, &candles, 0); assert_eq!(trend.lines.len(), 2); let (low, _) = trend.reach(30..40).expect("macd"); assert!(low <= 0.0, "the zero guide stays in view with the bars"); } }