1use crate::model::Bar;
91use crate::studies::palette;
92use crate::studies::stepper::Stepper;
93use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
94use egui::Color32;
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub enum PatternTrend {
99 #[default]
101 Sma50,
102 Sma50Sma200,
104 NoDetection,
106}
107
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
110pub enum PatternType {
111 #[default]
113 Both,
114 Bullish,
116 Bearish,
118}
119
120mod rules;
121
122use rules::Side::{Bearish, Bullish, Neutral};
123use rules::{BODY_AVERAGE, Candle, PATTERNS, Pattern, Side};
124
125#[derive(Clone)]
128pub struct CandlestickPatterns {
129 trend: PatternTrend,
130 pattern_type: PatternType,
131 patterns: [bool; PATTERNS.len()],
132 found: Vec<u64>,
134 values: IndicatorValues,
135 stepper: Stepper<State>,
136 colors: [Color32; 3],
137 visible: bool,
138}
139
140#[derive(Clone, Default)]
141struct State {
142 closes: std::collections::VecDeque<f64>,
143 bodies: std::collections::VecDeque<f64>,
144 average: Option<f64>,
145 candles: std::collections::VecDeque<Candle>,
146}
147
148impl CandlestickPatterns {
149 pub fn new(trend: PatternTrend) -> Self {
151 Self {
152 trend,
153 pattern_type: PatternType::Both,
154 patterns: [true; PATTERNS.len()],
155 found: Vec::new(),
156 values: IndicatorValues::new(),
157 stepper: Stepper::default(),
158 colors: [palette::BLUE, palette::RED, palette::GRAY],
159 visible: true,
160 }
161 }
162
163 pub fn with_pattern_type(mut self, pattern_type: PatternType) -> Self {
165 self.pattern_type = pattern_type;
166 self
167 }
168
169 pub fn with_patterns(mut self, keep: impl Fn(&str) -> bool) -> Self {
172 for (on, pattern) in self.patterns.iter_mut().zip(&PATTERNS) {
173 *on = keep(pattern.label);
174 }
175 self
176 }
177
178 fn found(&self, bar: usize, side: Side) -> impl Iterator<Item = &'static Pattern> + '_ {
180 let bits = self.found.get(bar).copied().unwrap_or(0);
181 PATTERNS
182 .iter()
183 .enumerate()
184 .filter(move |(k, p)| bits >> k & 1 == 1 && p.side == side)
185 .map(|(_, p)| p)
186 }
187}
188
189impl Default for CandlestickPatterns {
191 fn default() -> Self {
192 Self::new(PatternTrend::Sma50)
193 }
194}
195
196impl Indicator for CandlestickPatterns {
197 crate::studies::inputs::study_inputs!(self;
198 trend: choice("Detect trend based on", [PatternTrend::Sma50, PatternTrend::Sma50Sma200, PatternTrend::NoDetection]),
199 pattern_type: choice("Pattern type", [PatternType::Both, PatternType::Bullish, PatternType::Bearish]),
200 patterns[0]: toggle(PATTERNS[0].label),
201 patterns[1]: toggle(PATTERNS[1].label),
202 patterns[2]: toggle(PATTERNS[2].label),
203 patterns[3]: toggle(PATTERNS[3].label),
204 patterns[4]: toggle(PATTERNS[4].label),
205 patterns[5]: toggle(PATTERNS[5].label),
206 patterns[6]: toggle(PATTERNS[6].label),
207 patterns[7]: toggle(PATTERNS[7].label),
208 patterns[8]: toggle(PATTERNS[8].label),
209 patterns[9]: toggle(PATTERNS[9].label),
210 patterns[10]: toggle(PATTERNS[10].label),
211 patterns[11]: toggle(PATTERNS[11].label),
212 patterns[12]: toggle(PATTERNS[12].label),
213 patterns[13]: toggle(PATTERNS[13].label),
214 patterns[14]: toggle(PATTERNS[14].label),
215 patterns[15]: toggle(PATTERNS[15].label),
216 patterns[16]: toggle(PATTERNS[16].label),
217 patterns[17]: toggle(PATTERNS[17].label),
218 patterns[18]: toggle(PATTERNS[18].label),
219 patterns[19]: toggle(PATTERNS[19].label),
220 patterns[20]: toggle(PATTERNS[20].label),
221 patterns[21]: toggle(PATTERNS[21].label),
222 patterns[22]: toggle(PATTERNS[22].label),
223 patterns[23]: toggle(PATTERNS[23].label),
224 patterns[24]: toggle(PATTERNS[24].label),
225 patterns[25]: toggle(PATTERNS[25].label),
226 patterns[26]: toggle(PATTERNS[26].label),
227 patterns[27]: toggle(PATTERNS[27].label),
228 patterns[28]: toggle(PATTERNS[28].label),
229 patterns[29]: toggle(PATTERNS[29].label),
230 patterns[30]: toggle(PATTERNS[30].label),
231 patterns[31]: toggle(PATTERNS[31].label),
232 patterns[32]: toggle(PATTERNS[32].label),
233 patterns[33]: toggle(PATTERNS[33].label),
234 patterns[34]: toggle(PATTERNS[34].label),
235 patterns[35]: toggle(PATTERNS[35].label),
236 patterns[36]: toggle(PATTERNS[36].label),
237 patterns[37]: toggle(PATTERNS[37].label),
238 patterns[38]: toggle(PATTERNS[38].label),
239 patterns[39]: toggle(PATTERNS[39].label),
240 patterns[40]: toggle(PATTERNS[40].label),
241 patterns[41]: toggle(PATTERNS[41].label),
242 patterns[42]: toggle(PATTERNS[42].label),
243 patterns[43]: toggle(PATTERNS[43].label)
244 ; {});
245
246 fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
247 crate::studies::PlotStyle {
248 color: self.colors[line.min(2)],
249 width: 1.0,
250 pattern: crate::model::LineStyle::Solid,
251 plot_type: match line {
252 0 => crate::studies::PlotType::TriangleUp,
253 1 => crate::studies::PlotType::TriangleDown,
254 _ => crate::studies::PlotType::Circles,
255 },
256 visible: true,
257 }
258 }
259
260 fn plot_label_colors(&self, line: usize) -> Option<(Color32, Color32)> {
261 self.colors.get(line).map(|&color| (color, Color32::WHITE))
262 }
263
264 fn plot_text(&self, line: usize, bar: usize) -> Option<String> {
265 let side = [Bullish, Bearish, Neutral].get(line).copied()?;
266 let bearish = self.found(bar, Bearish).next().is_some();
267 let text: Vec<&str> = match side {
268 Bearish => self
270 .found(bar, Bearish)
271 .chain(self.found(bar, Neutral))
272 .map(|p| p.abbr)
273 .collect(),
274 Neutral if bearish => Vec::new(),
275 _ => self.found(bar, side).map(|p| p.abbr).collect(),
276 };
277 (!text.is_empty()).then(|| text.join("\n"))
278 }
279
280 fn name(&self) -> &str {
281 "All Candlestick Patterns"
282 }
283
284 fn desc(&self) -> &str {
285 "All Candlestick Patterns - Japanese candlestick patterns with a trend filter"
286 }
287
288 fn calculate(&mut self, data: &[Bar]) {
289 self.update(data, 0);
290 }
291
292 fn update(&mut self, data: &[Bar], from: usize) {
293 let len = self.values.len();
294 let start = if len > 0 && from >= len - 1 && data.len() >= len {
295 from.min(len)
296 } else {
297 0
298 };
299 self.found.truncate(start);
300 self.stepper
301 .run(data, from, &mut self.values, |state, bar| {
302 if ![bar.open, bar.high, bar.low, bar.close]
303 .iter()
304 .all(|v| v.is_finite())
305 {
306 self.found.push(0);
307 return IndicatorValue::None;
308 }
309 state.closes.push_back(bar.close);
310 if state.closes.len() > 200 {
311 state.closes.pop_front();
312 }
313 let sma = |period| {
314 if state.closes.len() < period {
315 f64::NAN
316 } else {
317 state
318 .closes
319 .iter()
320 .skip(state.closes.len() - period)
321 .sum::<f64>()
322 / period as f64
323 }
324 };
325 let (down, up) = match self.trend {
326 PatternTrend::Sma50 => {
327 let mean = sma(50);
328 (bar.close < mean, bar.close > mean)
329 }
330 PatternTrend::Sma50Sma200 => {
331 let (fast, slow) = (sma(50), sma(200));
332 (
333 bar.close < fast && fast < slow,
334 bar.close > fast && fast > slow,
335 )
336 }
337 PatternTrend::NoDetection => (true, true),
338 };
339 let body = (bar.close - bar.open).abs();
340 state.bodies.push_back(body);
341 if state.bodies.len() > BODY_AVERAGE {
342 state.bodies.pop_front();
343 }
344 let average = match state.average {
345 Some(previous) => {
346 previous + (2.0 / (BODY_AVERAGE + 1) as f64) * (body - previous)
347 }
348 None if state.bodies.len() == BODY_AVERAGE => {
349 if state.bodies.iter().all(|v| v.is_finite()) {
350 state.bodies.iter().sum::<f64>() / BODY_AVERAGE as f64
351 } else {
352 f64::NAN
353 }
354 }
355 None => f64::NAN,
356 };
357 if state.bodies.len() == BODY_AVERAGE {
358 state.average = Some(average);
359 }
360 state.candles.push_back(Candle {
361 open: bar.open,
362 high: bar.high,
363 low: bar.low,
364 close: bar.close,
365 average,
366 down,
367 up,
368 });
369 let max_bars = PATTERNS.iter().map(|pattern| pattern.bars).max().unwrap();
370 if state.candles.len() > max_bars {
371 state.candles.pop_front();
372 }
373 let candles = state.candles.make_contiguous();
374 let marked = |side| match self.pattern_type {
375 PatternType::Both => true,
376 PatternType::Bullish => side == Bullish,
377 PatternType::Bearish => side == Bearish,
378 };
379 let mut bits = 0;
380 for (bit, pattern) in PATTERNS.iter().enumerate() {
381 if self.patterns[bit]
382 && marked(pattern.side)
383 && candles.len() >= pattern.bars
384 && (pattern.found)(&candles[candles.len() - pattern.bars..])
385 {
386 bits |= 1 << bit;
387 }
388 }
389 self.found.push(bits);
390 let found = |side| {
391 PATTERNS
392 .iter()
393 .enumerate()
394 .any(|(bit, pattern)| bits >> bit & 1 == 1 && pattern.side == side)
395 };
396 let (bull, bear, neutral) = (found(Bullish), found(Bearish), found(Neutral));
397 IndicatorValue::Multiple(vec![
398 if bull { bar.low } else { f64::NAN },
399 if bear { bar.high } else { f64::NAN },
400 if neutral && !bear { bar.high } else { f64::NAN },
401 ])
402 });
403 }
404
405 fn is_incremental(&self) -> bool {
406 true
407 }
408
409 fn values(&self) -> &IndicatorValues {
410 &self.values
411 }
412
413 fn colors(&self) -> Vec<Color32> {
414 self.colors.to_vec()
415 }
416
417 fn set_colors(&mut self, colors: Vec<Color32>) {
418 for (slot, color) in self.colors.iter_mut().zip(colors) {
419 *slot = color;
420 }
421 }
422
423 fn line_names(&self) -> Vec<String> {
424 vec!["Bullish".into(), "Bearish".into(), "Neutral".into()]
425 }
426
427 fn is_visible(&self) -> bool {
428 self.visible
429 }
430
431 fn set_visible(&mut self, visible: bool) {
432 self.visible = visible;
433 }
434
435 fn clone_box(&self) -> Box<dyn Indicator> {
436 Box::new(self.clone())
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use chrono::Utc;
444
445 type Ohlc = (f64, f64, f64, f64);
446 type Case = (
448 &'static str,
449 usize,
450 &'static str,
451 &'static [Ohlc],
452 &'static [Ohlc],
453 );
454
455 type TasukiCase = (&'static str, usize, &'static str, [Ohlc; 2], Ohlc, bool);
457
458 fn walk(closes: impl IntoIterator<Item = f64>) -> Vec<Bar> {
460 let mut prev: Option<f64> = None;
461 closes
462 .into_iter()
463 .map(|close| {
464 let open = prev.unwrap_or(close - 1.0);
465 prev = Some(close);
466 let (top, bottom) = (open.max(close), open.min(close));
467 Bar::new(Utc::now(), open, top + 0.25, bottom - 0.25, close, 1.0)
468 })
469 .collect()
470 }
471
472 fn text(
474 trend: PatternTrend,
475 before: &[Bar],
476 bars: &[Ohlc],
477 label: &str,
478 line: usize,
479 ) -> Option<String> {
480 let mut data = before.to_vec();
481 data.extend(
482 bars.iter()
483 .map(|&(o, h, l, c)| Bar::new(Utc::now(), o, h, l, c, 1.0)),
484 );
485 let mut study =
486 CandlestickPatterns::new(trend).with_patterns(|p| label.is_empty() || p == label);
487 study.calculate(&data);
488 study.plot_text(line, data.len() - 1)
489 }
490
491 #[test]
495 fn patterns_match_their_definitions() {
496 let flat = walk((0..14).map(|i| 100.0 + f64::from(i % 2)));
497 #[rustfmt::skip]
498 let cases: [Case; 44] = [
499 ("Abandoned Baby - Bearish", 1, "AB",
500 &[(100.0, 103.0, 99.5, 102.5), (104.0, 105.0, 103.5, 104.0625), (103.0, 103.25, 100.0, 100.5)],
501 &[(100.0, 103.0, 99.5, 102.5), (104.0, 105.0, 103.5, 104.0625), (103.0, 103.5, 100.0, 100.5)]),
502 ("Abandoned Baby - Bullish", 0, "AB",
503 &[(102.5, 103.0, 99.5, 100.0), (98.5, 99.0, 97.5, 98.5625), (99.5, 102.5, 99.25, 102.0)],
504 &[(102.5, 103.0, 99.5, 100.0), (98.5, 99.0, 97.5, 98.5625), (99.5, 102.5, 99.0, 102.0)]),
505 ("Dark Cloud Cover - Bearish", 1, "DCC",
506 &[(100.0, 103.5, 99.5, 103.0), (104.0, 104.5, 101.0, 101.25)],
507 &[(100.0, 103.5, 99.5, 103.0), (104.0, 104.5, 101.5, 101.75)]),
508 ("Doji", 2, "D", &[(100.0, 102.0, 98.0, 100.0625)], &[(100.0, 102.0, 98.0, 100.5)]),
509 ("Doji Star - Bearish", 1, "DS",
510 &[(100.0, 103.25, 99.75, 103.0), (103.5, 104.5, 102.5, 103.5625)],
511 &[(100.0, 103.25, 99.75, 103.0), (102.75, 103.75, 101.75, 102.8125)]),
512 ("Doji Star - Bullish", 0, "DS",
513 &[(103.0, 103.25, 99.75, 100.0), (99.5, 100.5, 98.5, 99.4375)],
514 &[(103.0, 103.25, 99.75, 100.0), (100.25, 101.25, 99.25, 100.1875)]),
515 ("Downside Tasuki Gap - Bearish", 1, "DTG",
516 &[(104.0, 104.25, 99.75, 100.0), (99.5, 99.75, 98.75, 99.0), (99.25, 100.25, 98.75, 99.75)],
517 &[(104.0, 104.25, 99.75, 100.0), (99.5, 99.75, 98.75, 99.0), (99.25, 100.5, 98.75, 100.25)]),
518 ("Dragonfly Doji - Bullish", 0, "DD",
519 &[(100.0, 100.0625, 96.0, 100.0625)], &[(100.0, 100.5, 96.0, 100.0625)]),
520 ("Engulfing - Bearish", 1, "BE",
521 &[(100.5, 101.25, 100.25, 101.0), (101.25, 101.5, 99.75, 100.0)],
522 &[(100.5, 101.25, 100.25, 101.0), (100.75, 101.0, 99.25, 99.5)]),
523 ("Engulfing - Bullish", 0, "BE",
524 &[(101.0, 101.25, 100.25, 100.5), (100.25, 101.75, 100.0, 101.5)],
525 &[(101.0, 101.25, 100.25, 100.5), (100.75, 102.0, 100.5, 102.0)]),
526 ("Evening Doji Star - Bearish", 1, "EDS",
527 &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.0625), (104.5, 104.75, 101.0, 101.5)],
528 &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.5), (104.5, 104.75, 101.0, 101.5)]),
529 ("Evening Star - Bearish", 1, "ES",
530 &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.5), (104.5, 104.75, 101.0, 101.5)],
531 &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.5), (104.5, 104.75, 102.0, 102.25)]),
532 ("Falling Three Methods - Bearish", 1, "FTM",
533 &[(104.0, 104.5, 99.5, 100.0), (101.0, 101.75, 100.75, 101.5), (101.0, 101.75, 100.75, 101.5),
534 (101.0, 101.75, 100.75, 101.5), (101.5, 101.75, 98.5, 99.0)],
535 &[(104.0, 104.5, 99.5, 100.0), (101.0, 101.75, 100.75, 101.5), (101.0, 101.75, 100.75, 101.5),
536 (101.0, 101.75, 100.75, 101.5), (101.5, 101.75, 100.0, 100.25)]),
537 ("Falling Window - Bearish", 1, "FW",
538 &[(101.0, 101.5, 99.5, 100.0), (99.0, 99.25, 98.0, 98.5)],
539 &[(101.0, 101.5, 99.5, 100.0), (99.0, 99.5, 98.0, 98.5)]),
540 ("Gravestone Doji - Bearish", 1, "GD",
541 &[(100.0, 104.0, 100.0, 100.0625)], &[(100.0, 104.0, 99.5, 100.0625)]),
542 ("Hammer - Bullish", 0, "H", &[(100.0, 100.5, 98.0, 100.5)], &[(100.0, 100.75, 98.0, 100.5)]),
543 ("Hanging Man - Bearish", 1, "HM", &[(100.0, 100.5, 98.0, 100.5)], &[(100.0, 100.75, 98.0, 100.5)]),
544 ("Harami - Bearish", 1, "BH",
545 &[(100.0, 104.5, 99.5, 104.0), (102.5, 103.0, 101.5, 102.0)],
546 &[(100.0, 104.5, 99.5, 104.0), (102.5, 104.25, 101.5, 102.0)]),
547 ("Harami - Bullish", 0, "BH",
548 &[(104.0, 104.5, 99.5, 100.0), (101.5, 103.0, 101.0, 102.0)],
549 &[(104.0, 104.5, 99.5, 100.0), (101.5, 103.0, 99.75, 102.0)]),
550 ("Harami Cross - Bearish", 1, "HC",
551 &[(100.0, 104.5, 99.5, 104.0), (102.0, 103.0, 101.0, 102.0625)],
552 &[(100.0, 104.5, 99.5, 104.0), (102.0, 103.0, 101.0, 102.25)]),
553 ("Harami Cross - Bullish", 0, "HC",
554 &[(104.0, 104.5, 99.5, 100.0), (102.0, 103.0, 101.0, 102.0625)],
555 &[(104.0, 104.5, 99.5, 100.0), (102.0, 103.0, 101.0, 102.25)]),
556 ("Inverted Hammer - Bullish", 0, "IH", &[(100.0, 102.5, 100.0, 100.5)], &[(100.0, 102.5, 99.75, 100.5)]),
557 ("Kicking - Bearish", 1, "K",
558 &[(105.0, 107.0, 105.0, 107.0), (104.0, 104.0, 102.0, 102.0)],
559 &[(105.0, 107.0, 105.0, 107.0), (105.0, 105.0, 103.0, 103.0)]),
560 ("Kicking - Bullish", 0, "K",
561 &[(104.0, 104.0, 102.0, 102.0), (105.0, 107.0, 105.0, 107.0)],
562 &[(104.0, 104.0, 102.0, 102.0), (104.0, 106.0, 104.0, 106.0)]),
563 ("Long Lower Shadow - Bullish", 0, "LLS", &[(100.0, 100.5, 96.0, 100.25)], &[(100.0, 101.0, 97.0, 100.25)]),
564 ("Long Upper Shadow - Bearish", 1, "LUS", &[(100.0, 104.0, 99.5, 99.75)], &[(100.0, 103.0, 99.0, 99.75)]),
565 ("Marubozu Black - Bearish", 1, "MB", &[(102.0, 102.0, 100.0, 100.0)], &[(102.0, 102.0, 99.75, 100.0)]),
566 ("Marubozu White - Bullish", 0, "MW", &[(100.0, 102.0, 100.0, 102.0)], &[(100.0, 102.25, 100.0, 102.0)]),
567 ("Morning Doji Star - Bullish", 0, "MDS",
568 &[(104.0, 104.25, 99.75, 100.0), (98.5, 99.5, 98.0, 98.5625), (99.5, 103.0, 99.25, 102.5)],
569 &[(104.0, 104.25, 99.75, 100.0), (99.0, 99.5, 98.0, 98.5), (99.5, 103.0, 99.25, 102.5)]),
570 ("Morning Star - Bullish", 0, "MS",
571 &[(104.0, 104.25, 99.75, 100.0), (99.0, 99.5, 98.0, 98.5), (99.5, 103.0, 99.25, 102.5)],
572 &[(104.0, 104.25, 99.75, 100.0), (99.0, 99.5, 98.0, 98.5), (99.5, 102.0, 99.25, 101.75)]),
573 ("On Neck - Bearish", 1, "ON",
574 &[(104.0, 104.5, 99.75, 100.0), (99.0, 100.0, 98.75, 99.75)],
575 &[(104.0, 104.5, 99.75, 100.0), (99.0, 100.5, 98.75, 100.25)]),
576 ("Piercing - Bullish", 0, "P",
577 &[(103.0, 103.5, 99.5, 100.0), (99.0, 102.0, 98.5, 101.75)],
578 &[(103.0, 103.5, 99.5, 100.0), (99.0, 102.0, 98.5, 101.25)]),
579 ("Rising Three Methods - Bullish", 0, "RTM",
580 &[(100.0, 104.5, 99.5, 104.0), (103.0, 103.25, 102.25, 102.5), (103.0, 103.25, 102.25, 102.5),
581 (103.0, 103.25, 102.25, 102.5), (102.5, 105.25, 102.25, 105.0)],
582 &[(100.0, 104.5, 99.5, 104.0), (103.0, 103.25, 102.25, 102.5), (103.0, 103.25, 102.25, 102.5),
583 (103.0, 103.25, 102.25, 102.5), (102.5, 104.0, 102.25, 103.75)]),
584 ("Rising Window - Bullish", 0, "RW",
585 &[(100.0, 101.5, 99.5, 101.0), (102.0, 103.0, 101.75, 102.5)],
586 &[(100.0, 101.5, 99.5, 101.0), (102.0, 103.0, 101.5, 102.5)]),
587 ("Shooting Star - Bearish", 1, "SS", &[(100.0, 102.5, 100.0, 100.5)], &[(100.0, 102.5, 99.75, 100.5)]),
588 ("Spinning Top Black", 2, "STB", &[(100.5, 102.0, 98.0, 100.0)], &[(100.5, 101.75, 98.0, 100.0)]),
589 ("Spinning Top White", 2, "STW", &[(100.0, 102.0, 98.0, 100.5)], &[(100.0, 101.75, 98.0, 100.5)]),
590 ("Three Black Crows - Bearish", 1, "3BC",
591 &[(104.0, 104.5, 102.0, 102.0), (103.0, 103.5, 101.0, 101.0), (102.0, 102.5, 100.0, 100.0)],
592 &[(104.0, 104.5, 102.0, 102.0), (103.0, 103.5, 101.0, 101.0), (102.0, 102.5, 99.5, 100.0)]),
593 ("Three White Soldiers - Bullish", 0, "3WS",
594 &[(100.0, 102.0, 99.5, 102.0), (101.0, 103.0, 100.5, 103.0), (102.0, 104.0, 101.5, 104.0)],
595 &[(100.0, 102.0, 99.5, 102.0), (101.0, 103.0, 100.5, 103.0), (102.0, 104.5, 101.5, 104.0)]),
596 ("Tri-Star - Bearish", 1, "3S",
597 &[(100.0, 101.0, 99.0, 100.0), (102.0, 103.0, 101.0, 102.0), (100.0, 101.0, 99.0, 100.0)],
598 &[(100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0)]),
599 ("Tri-Star - Bullish", 0, "3S",
600 &[(100.0, 101.0, 99.0, 100.0), (98.0, 99.0, 97.0, 98.0), (100.0, 101.0, 99.0, 100.0)],
601 &[(100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0)]),
602 ("Tweezer Bottom - Bullish", 0, "TB",
603 &[(103.0, 103.25, 99.5, 100.0), (100.0, 101.5, 99.5, 101.0)],
604 &[(103.0, 103.25, 99.5, 100.0), (100.0, 101.5, 99.25, 101.0)]),
605 ("Tweezer Top - Bearish", 1, "TT",
606 &[(100.0, 103.5, 99.75, 103.0), (103.0, 103.5, 101.5, 102.0)],
607 &[(100.0, 103.5, 99.75, 103.0), (103.0, 103.75, 101.5, 102.0)]),
608 ("Upside Tasuki Gap - Bullish", 0, "UTG",
609 &[(100.0, 104.25, 99.75, 104.0), (104.5, 105.25, 104.25, 105.0), (104.75, 105.25, 104.0, 104.25)],
610 &[(100.0, 104.25, 99.75, 104.0), (104.5, 105.25, 104.25, 105.0), (104.75, 105.25, 103.5, 103.75)]),
611 ];
612 let none = PatternTrend::NoDetection;
613 let falling = walk((90..150).rev().map(f64::from));
617 let rising = walk((40..100).map(f64::from));
618 for (label, line, abbr, hit, miss) in cases {
619 assert_eq!(
620 text(none, &flat, hit, label, line).as_deref(),
621 Some(abbr),
622 "{label}"
623 );
624 assert_eq!(text(none, &flat, miss, label, 0), None, "{label} near miss");
625 assert_eq!(text(none, &flat, miss, label, 1), None, "{label} near miss");
626 assert_eq!(text(none, &flat, miss, label, 2), None, "{label} near miss");
627 let trendless = [
628 "D", "DD", "GD", "K", "LLS", "LUS", "MB", "MW", "STB", "STW", "3BC", "3WS",
629 ];
630 let continuation = ["DTG", "FTM", "FW", "ON", "RTM", "RW", "UTG"];
631 let (with, against) = if (line == 0) != continuation.contains(&abbr) {
632 (&falling, &rising)
633 } else {
634 (&rising, &falling)
635 };
636 let sma = PatternTrend::Sma50;
637 assert_eq!(
638 text(sma, with, hit, label, line).as_deref(),
639 Some(abbr),
640 "{label} in trend"
641 );
642 assert_eq!(
643 text(sma, against, hit, label, line).as_deref(),
644 trendless.contains(&abbr).then_some(abbr),
645 "{label} against trend"
646 );
647 }
648
649 let window: &[Ohlc] = &[(90.0, 90.25, 88.75, 89.0), (120.0, 121.0, 119.5, 120.5)];
652 let rw = "Rising Window - Bullish";
653 assert_eq!(text(PatternTrend::Sma50, &falling, window, rw, 0), None);
654
655 let hammer: &[Ohlc] = &[(89.0, 89.5, 87.0, 89.5)];
659 let turned = walk((1..=150).chain((90..150).rev()).map(f64::from));
660 let long_fall = walk((90..300).rev().map(f64::from));
661 let h = "Hammer - Bullish";
662 assert_eq!(
663 text(PatternTrend::Sma50, &falling, hammer, h, 0).as_deref(),
664 Some("H")
665 );
666 assert_eq!(text(PatternTrend::Sma50, &rising, hammer, h, 0), None);
667 assert_eq!(
668 text(PatternTrend::Sma50, &turned, hammer, h, 0).as_deref(),
669 Some("H")
670 );
671 assert_eq!(text(PatternTrend::Sma50Sma200, &turned, hammer, h, 0), None);
672 assert_eq!(
673 text(PatternTrend::Sma50Sma200, &long_fall, hammer, h, 0).as_deref(),
674 Some("H")
675 );
676
677 let dragonfly: &[Ohlc] = &[(100.0, 100.0625, 96.0, 100.0625)];
679 assert_eq!(
680 text(none, &flat, dragonfly, "", 0).as_deref(),
681 Some("DD\nH\nLLS")
682 );
683
684 let tri_star: &[Ohlc] = &[
686 (100.0, 101.0, 99.0, 100.0),
687 (102.0, 103.0, 101.0, 102.0),
688 (100.0, 101.0, 99.0, 100.0),
689 ];
690 assert_eq!(text(none, &flat, tri_star, "", 1).as_deref(), Some("3S\nD"));
691 assert_eq!(text(none, &flat, tri_star, "", 2), None);
692
693 let mut data = flat.clone();
695 data.extend(
696 tri_star
697 .iter()
698 .map(|&(o, h, l, c)| Bar::new(Utc::now(), o, h, l, c, 1.0)),
699 );
700 let mut study = CandlestickPatterns::new(none).with_pattern_type(PatternType::Bullish);
701 study.calculate(&data);
702 let last = data.len() - 1;
703 assert_eq!(study.plot_text(1, last), None);
704 assert_eq!(study.plot_text(2, last), None);
705 }
706
707 #[test]
711 fn tasuki_gap_needs_open_in_body_and_close_in_gap() {
712 let flat = walk((0..14).map(|i| 100.0 + f64::from(i % 2)));
713 let down: [Ohlc; 2] = [(104.0, 104.25, 99.75, 100.0), (99.5, 99.75, 98.75, 99.0)];
714 let up: [Ohlc; 2] = [
715 (100.0, 104.25, 99.75, 104.0),
716 (104.5, 105.25, 104.25, 105.0),
717 ];
718 #[rustfmt::skip]
719 let cases: [TasukiCase; 10] = [
720 ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.25, 100.25, 98.75, 99.75), true),
721 ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (98.75, 99.75, 98.5, 99.75), false),
722 ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.75, 100.0, 99.5, 99.875), false),
723 ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.25, 99.75, 99.0, 99.5), false),
724 ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.25, 100.25, 99.0, 100.0), false),
725 ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.75, 105.25, 104.0, 104.25), true),
726 ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.25, 104.5, 104.0, 104.125), false),
727 ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (105.5, 105.75, 104.0, 104.25), false),
728 ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.75, 105.0, 104.5, 104.5), false),
729 ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.75, 105.0, 103.75, 104.0), false),
730 ];
731 for (label, line, abbr, before, third, hit) in cases {
732 let bars = [before[0], before[1], third];
733 assert_eq!(
734 text(PatternTrend::NoDetection, &flat, &bars, label, line).as_deref(),
735 hit.then_some(abbr),
736 "{label} third bar {third:?}"
737 );
738 }
739 }
740}