1use crate::model::Bar;
39use crate::studies::palette;
40use crate::studies::{
41 CCI, EMA, Indicator, IndicatorValue, IndicatorValues, MACD, Momentum, RSI, SMA, Stochastic,
42 StudyCell, StudyTable,
43};
44use egui::Color32;
45
46#[derive(Clone, Copy, PartialEq)]
48enum Group {
49 MovingAverage,
50 Oscillator,
51}
52
53#[derive(Clone, Copy)]
54enum Rule {
55 Average,
56 Rsi,
57 Stochastic,
58 Cci,
59 Macd,
60 Momentum,
61}
62
63impl Rule {
64 fn vote(self, values: &IndicatorValues, at: usize, close: f64) -> Option<f64> {
66 let signed = |difference: f64| {
67 if difference > 0.0 {
68 1.0
69 } else if difference < 0.0 {
70 -1.0
71 } else {
72 0.0
73 }
74 };
75 let level = |low: f64, high: f64| {
76 let value = values.value(0, at)?;
77 Some(if value < low {
78 1.0
79 } else if value > high {
80 -1.0
81 } else {
82 0.0
83 })
84 };
85 match self {
86 Self::Average => Some(signed(close - values.value(0, at)?)),
87 Self::Rsi => level(30.0, 70.0),
88 Self::Stochastic => level(20.0, 80.0),
89 Self::Cci => level(-100.0, 100.0),
90 Self::Macd => Some(signed(values.value(0, at)? - values.value(1, at)?)),
91 Self::Momentum => Some(signed(values.value(0, at)?)),
92 }
93 }
94}
95
96#[derive(Clone)]
97struct Component {
98 group: Group,
99 rule: Rule,
100 warmup: usize,
102 study: Box<dyn Indicator>,
103}
104
105#[derive(Clone)]
107pub struct TechnicalRatings {
108 fast: usize,
109 medium: usize,
110 slow: usize,
111 simple: bool,
112 exponential: bool,
113 rsi: bool,
114 rsi_period: usize,
115 stochastic: bool,
116 stochastic_period: usize,
117 cci: bool,
118 cci_period: usize,
119 macd: bool,
120 macd_fast: usize,
121 macd_slow: usize,
122 macd_signal: usize,
123 momentum: bool,
124 momentum_period: usize,
125 components: Option<Vec<Component>>,
126 bars: Vec<Bar>,
129 sources: Vec<usize>,
130 values: IndicatorValues,
131 table: StudyTable,
132 colors: Vec<Color32>,
133 visible: bool,
134}
135
136impl TechnicalRatings {
137 pub fn new(fast: usize, medium: usize, slow: usize) -> Self {
140 Self {
141 fast: fast.max(1),
142 medium: medium.max(1),
143 slow: slow.max(1),
144 simple: true,
145 exponential: true,
146 rsi: true,
147 rsi_period: 14,
148 stochastic: true,
149 stochastic_period: 14,
150 cci: true,
151 cci_period: 20,
152 macd: true,
153 macd_fast: 12,
154 macd_slow: 26,
155 macd_signal: 9,
156 momentum: true,
157 momentum_period: 10,
158 components: None,
159 bars: Vec::new(),
160 sources: Vec::new(),
161 values: IndicatorValues::new(),
162 table: StudyTable {
163 title: "Technical Ratings".into(),
164 columns: ["Group", "Score", "Rating", "Coverage"]
165 .map(String::from)
166 .to_vec(),
167 rows: Vec::new(),
168 },
169 colors: vec![
170 palette::DISABLED,
171 palette::DISABLED,
172 palette::DISABLED,
173 palette::BLUE,
174 palette::BLUE,
175 palette::BLUE,
176 palette::BRIGHT_RED,
177 palette::BRIGHT_RED,
178 palette::BRIGHT_RED,
179 ],
180 visible: true,
181 }
182 }
183
184 fn build(&self) -> Vec<Component> {
185 let mut components = Vec::new();
186 let mut add = |group, rule, warmup, study: Box<dyn Indicator>| {
187 components.push(Component {
188 group,
189 rule,
190 warmup,
191 study,
192 });
193 };
194 for length in [self.fast, self.medium, self.slow] {
195 if self.simple {
196 add(
197 Group::MovingAverage,
198 Rule::Average,
199 0,
200 Box::new(SMA::new(length)),
201 );
202 }
203 if self.exponential {
204 add(
205 Group::MovingAverage,
206 Rule::Average,
207 0,
208 Box::new(EMA::new(length)),
209 );
210 }
211 }
212 let oscillators: [(bool, Rule, usize, Box<dyn Indicator>); 5] = [
213 (self.rsi, Rule::Rsi, 0, Box::new(RSI::new(self.rsi_period))),
214 (
215 self.stochastic,
216 Rule::Stochastic,
217 0,
218 Box::new(Stochastic::new(self.stochastic_period, 3, 3)),
219 ),
220 (self.cci, Rule::Cci, 0, Box::new(CCI::new(self.cci_period))),
221 (
224 self.macd,
225 Rule::Macd,
226 self.macd_slow.max(self.macd_signal) - 1,
227 Box::new(MACD::new(self.macd_fast, self.macd_slow, self.macd_signal)),
228 ),
229 (
230 self.momentum,
231 Rule::Momentum,
232 0,
233 Box::new(Momentum::new(self.momentum_period)),
234 ),
235 ];
236 for (enabled, rule, warmup, study) in oscillators {
237 if enabled {
238 add(Group::Oscillator, rule, warmup, study);
239 }
240 }
241 components
242 }
243
244 fn row(components: &[Component], at: usize, close: f64) -> [f64; 6] {
246 let mut sum = [0.0; 2];
248 let mut votes = [0usize; 2];
249 let mut enabled = [0usize; 2];
250 for component in components {
251 let group = usize::from(component.group == Group::Oscillator);
252 enabled[group] += 1;
253 if let Some(vote) = (at >= component.warmup)
254 .then(|| component.rule.vote(component.study.values(), at, close))
255 .flatten()
256 {
257 sum[group] += vote;
258 votes[group] += 1;
259 }
260 }
261 let score = |sum: f64, votes: usize| {
262 if votes == 0 {
263 f64::NAN
264 } else {
265 sum / votes as f64
266 }
267 };
268 let coverage = |votes: usize, enabled: usize| {
269 if enabled == 0 {
270 f64::NAN
271 } else {
272 votes as f64 / enabled as f64
273 }
274 };
275 [
276 score(sum[0], votes[0]),
277 score(sum[1], votes[1]),
278 score(sum[0] + sum[1], votes[0] + votes[1]),
279 coverage(votes[0], enabled[0]),
280 coverage(votes[1], enabled[1]),
281 coverage(votes[0] + votes[1], enabled[0] + enabled[1]),
282 ]
283 }
284}
285
286impl Default for TechnicalRatings {
288 fn default() -> Self {
289 Self::new(10, 50, 200)
290 }
291}
292
293fn rating(score: f64) -> &'static str {
295 if score >= 0.5 {
296 "Strong Buy"
297 } else if score >= 0.1 {
298 "Buy"
299 } else if score > -0.1 {
300 "Neutral"
301 } else if score > -0.5 {
302 "Sell"
303 } else {
304 "Strong Sell"
305 }
306}
307
308impl Indicator for TechnicalRatings {
309 crate::studies::inputs::study_inputs!(self;
310 fast: length("Fast moving average", 1, usize::MAX),
311 medium: length("Medium moving average", 1, usize::MAX),
312 slow: length("Slow moving average", 1, usize::MAX),
313 simple: toggle("Simple moving averages"),
314 exponential: toggle("Exponential moving averages"),
315 rsi: toggle("RSI"),
316 rsi_period: length("RSI period", 1, usize::MAX),
317 stochastic: toggle("Stochastic"),
318 stochastic_period: length("Stochastic period", 1, usize::MAX),
319 cci: toggle("CCI"),
320 cci_period: length("CCI period", 1, usize::MAX),
321 macd: toggle("MACD"),
322 macd_fast: length("MACD fast period", 1, usize::MAX),
323 macd_slow: length("MACD slow period", 1, usize::MAX),
324 macd_signal: length("MACD signal period", 1, usize::MAX),
325 momentum: toggle("Momentum"),
326 momentum_period: length("Momentum period", 1, usize::MAX)
327 ; {
328 self.components = None;
329 });
330
331 fn name(&self) -> &str {
332 "Technical Ratings"
333 }
334
335 fn desc(&self) -> &str {
336 "Technical Ratings - Buy and sell votes of moving averages and oscillators, scored from -1 to 1"
337 }
338
339 fn calculate(&mut self, data: &[Bar]) {
340 self.update(data, 0);
341 }
342
343 fn update(&mut self, data: &[Bar], from: usize) {
344 let mut from = from.min(self.values.len()).min(data.len());
345 if self.components.is_none() {
346 self.components = Some(self.build());
347 from = 0;
348 }
349 let components = self.components.as_mut().expect("components built");
350 let kept = self.sources.partition_point(|&source| source < from);
351 self.sources.truncate(kept);
352 self.bars.truncate(kept);
353 let usable =
354 |bar: &Bar| bar.high.is_finite() && bar.low.is_finite() && bar.close.is_finite();
355 for (source, bar) in data.iter().enumerate().skip(from) {
356 if usable(bar) {
357 self.sources.push(source);
358 self.bars.push(bar.clone());
359 }
360 }
361 for component in components.iter_mut() {
362 component.study.update(&self.bars, kept);
363 }
364 self.values.truncate(from);
365 let mut at = kept;
366 for bar in &data[from..] {
367 if usable(bar) {
368 self.values
369 .push_lines(&Self::row(components, at, bar.close));
370 at += 1;
371 } else {
372 self.values.push(IndicatorValue::None);
373 }
374 }
375 let latest = self.values.len().checked_sub(1);
376 self.table.rows = ["Moving averages", "Oscillators", "Summary"]
377 .into_iter()
378 .enumerate()
379 .map(|(group, label)| {
380 let at = |line: usize| latest.and_then(|bar| self.values.value(line, bar));
381 let (score, coverage) = (at(group), at(group + 3));
382 vec![
383 StudyCell::Text(label.into()),
384 StudyCell::Text(score.map_or("N/A".into(), |score| format!("{score:+.2}"))),
385 StudyCell::Text(score.map_or("N/A", rating).into()),
386 StudyCell::Text(
387 coverage
388 .map_or("N/A".into(), |coverage| format!("{:.0}%", coverage * 100.0)),
389 ),
390 ]
391 })
392 .collect();
393 }
394
395 fn is_incremental(&self) -> bool {
396 true
397 }
398
399 fn table(&self) -> Option<&StudyTable> {
400 Some(&self.table)
401 }
402
403 fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
404 crate::studies::PlotStyle {
405 color: self.colors[line % 3],
406 width: 1.0,
407 pattern: crate::model::LineStyle::Solid,
408 plot_type: crate::studies::PlotType::Columns,
409 visible: line < 3,
410 }
411 }
412
413 fn values(&self) -> &IndicatorValues {
414 &self.values
415 }
416
417 fn colors(&self) -> Vec<Color32> {
418 self.colors.clone()
419 }
420
421 fn set_colors(&mut self, colors: Vec<Color32>) {
422 if colors.len() < 9 {
423 for (line, color) in colors.into_iter().take(3).enumerate() {
424 self.set_color(line, color);
425 }
426 } else {
427 for (old, new) in self.colors.iter_mut().zip(colors) {
428 *old = new;
429 }
430 }
431 }
432
433 fn set_color(&mut self, line: usize, color: Color32) {
434 if line < 3 {
435 for slot in [line, line + 3, line + 6] {
436 self.colors[slot] = color;
437 }
438 }
439 }
440
441 fn plot_color(&self, line: usize, bar: usize) -> Option<Color32> {
442 if line >= 3 {
443 return None;
444 }
445 let value = self.values.value(line, bar)?;
446 let neutral = self.colors[line];
447 let endpoint = self.colors[line + if value >= 0.0 { 3 } else { 6 }];
448 if neutral == endpoint {
449 return Some(neutral);
450 }
451 let color = neutral.lerp_to_gamma(endpoint, (value.abs() / 0.2).min(1.0) as f32);
452 let mut previous = value;
453 let mut streak = 0;
454 for at in (0..bar).rev().take(5) {
455 let Some(earlier) = self.values.value(line, at) else {
456 break;
457 };
458 if (value > 0.0 && previous > earlier) || (value < 0.0 && previous < earlier) {
459 streak += 1;
460 previous = earlier;
461 } else {
462 break;
463 }
464 }
465 Some(color.gamma_multiply_u8(((0.5 + streak as f32 * 0.1) * 255.0).round() as u8))
466 }
467
468 fn pane(&self) -> crate::studies::StudyPane {
469 crate::studies::StudyPane {
470 height: None,
471 range: Some((-1.1, 1.1)),
472 levels: vec![
473 (
474 0.5,
475 Color32::from_rgba_unmultiplied(91, 156, 246, 128),
476 "Strong Buy",
477 ),
478 (
479 -0.5,
480 Color32::from_rgba_unmultiplied(239, 154, 154, 128),
481 "Strong Sell",
482 ),
483 ],
484 fill_between_levels: false,
485 }
486 }
487
488 fn is_overlay(&self) -> bool {
489 false
490 }
491
492 fn is_visible(&self) -> bool {
493 self.visible
494 }
495
496 fn set_visible(&mut self, visible: bool) {
497 self.visible = visible;
498 }
499
500 fn clone_box(&self) -> Box<dyn Indicator> {
501 Box::new(self.clone())
502 }
503
504 fn line_names(&self) -> Vec<String> {
505 [
506 "Moving Averages",
507 "Oscillators",
508 "Summary",
509 "Moving Averages Coverage",
510 "Oscillators Coverage",
511 "Summary Coverage",
512 ]
513 .map(String::from)
514 .to_vec()
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::studies::InputValue;
522 use chrono::{DateTime, Duration};
523
524 #[test]
529 fn scores_coverage_and_ratings_follow_the_votes() {
530 let bars: Vec<_> = [10.0, 12.0, 11.4]
531 .into_iter()
532 .enumerate()
533 .map(|(i, c)| {
534 Bar::new(
535 DateTime::UNIX_EPOCH + Duration::days(i as i64),
536 c,
537 c,
538 c,
539 c,
540 1.0,
541 )
542 })
543 .collect();
544 let mut study = TechnicalRatings::default();
545 for (id, value) in [
546 ("fast", InputValue::Length(2)),
547 ("medium", InputValue::Length(2)),
548 ("slow", InputValue::Length(2)),
549 ("momentum_period", InputValue::Length(1)),
550 ("rsi", InputValue::Toggle(false)),
551 ("stochastic", InputValue::Toggle(false)),
552 ("cci", InputValue::Toggle(false)),
553 ("macd", InputValue::Toggle(false)),
554 ] {
555 study.set_input(id, value).unwrap();
556 }
557 study.calculate(&bars);
558 let expected = [
561 [0.0, f64::NAN, 0.0, 0.5, 0.0, 3.0 / 7.0],
562 [1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
563 [0.0, -1.0, -1.0 / 7.0, 1.0, 1.0, 1.0],
564 ];
565 for (bar, row) in expected.iter().enumerate() {
566 for (line, want) in row.iter().enumerate() {
567 match study.values().value(line, bar) {
568 Some(got) => assert!((got - want).abs() < 1e-12, "line {line} bar {bar}"),
569 None => assert!(want.is_nan(), "line {line} bar {bar}"),
570 }
571 }
572 }
573 let rows: Vec<Vec<_>> = study
574 .table()
575 .unwrap()
576 .rows
577 .iter()
578 .map(|row| match &row[..] {
579 [
580 _,
581 StudyCell::Text(score),
582 StudyCell::Text(rating),
583 StudyCell::Text(coverage),
584 ] => {
585 vec![score.clone(), rating.clone(), coverage.clone()]
586 }
587 _ => panic!("text cells"),
588 })
589 .collect();
590 assert_eq!(
591 rows,
592 [
593 ["+0.00", "Neutral", "100%"],
594 ["-1.00", "Strong Sell", "100%"],
595 ["-0.14", "Sell", "100%"],
596 ]
597 );
598 for (score, label) in [
599 (0.5, "Strong Buy"),
600 (0.1, "Buy"),
601 (0.09, "Neutral"),
602 (-0.09, "Neutral"),
603 (-0.1, "Sell"),
604 (-0.49, "Sell"),
605 (-0.5, "Strong Sell"),
606 ] {
607 assert_eq!(rating(score), label, "{score}");
608 }
609 }
610}