Skip to main content

egui_charts/studies/builtin/
symbol_pair.rs

1//! Ratio, spread and rolling correlation of two timestamp-aligned series.
2//!
3//! Each of the two series is a price source of the chart symbol, when its symbol is empty, or of
4//! a host-supplied symbol at the chart's interval. The studies are calculated per chart bar:
5//! the first series is `a`, the second `b`, and a series is aligned to a bar when it has a bar
6//! in the same interval.
7//!
8//! # Rule
9//!
10//! ```text
11//! Ratio:       a / b
12//! Spread:      a - b
13//! Correlation: Pearson correlation of the last `length` values of a and b
14//! ```
15//!
16//! With log returns the correlation uses `ln(v[t] / v[t - 1])` of each series over the last
17//! `length` returns, which needs `length + 1` consecutive bars.
18//!
19//! # Missing data
20//!
21//! A bar where either series has no bar of its own, or a non-finite value, has no output. Nothing
22//! is carried forward or taken from the chart in its place, and no gap is bridged: a correlation
23//! window that holds a missing bar has no value until the bar leaves it. A zero ratio denominator,
24//! a non-positive price in the log-return mode and a series that is constant over the window,
25//! whose correlation has zero variance, are unavailable, never infinite or invented.
26//!
27//! Hosts supply the histories of the symbols named by the inputs, as for any other study with
28//! [`Indicator::data_requests`]; no market data service is included.
29
30use crate::model::{Bar, PriceSource};
31use crate::studies::palette;
32use crate::studies::{
33    Indicator, IndicatorValues, InputKind, InputValue, StudyData, StudyDataRequest, StudyInput,
34};
35use egui::Color32;
36
37/// The first series divided by the second.
38pub type Ratio = SymbolPair<true, false>;
39/// The first series minus the second.
40pub type Spread = SymbolPair<false, false>;
41/// Rolling Pearson correlation of the two series.
42pub type Correlation = SymbolPair<false, true>;
43
44/// A ratio, spread or correlation of two series, each a price source of a symbol. `RATIO` selects
45/// the ratio and `CORRELATION` the correlation; neither selects the spread. With both `true`,
46/// `CORRELATION` takes precedence and the study is the correlation.
47#[derive(Clone)]
48pub struct SymbolPair<const RATIO: bool, const CORRELATION: bool> {
49    symbols: [String; 2],
50    sources: [PriceSource; 2],
51    length: usize,
52    log_returns: bool,
53    /// Both series on each calculated bar, NaN where one is unavailable.
54    pairs: Vec<[f64; 2]>,
55    values: IndicatorValues,
56    color: Color32,
57    visible: bool,
58}
59
60impl<const RATIO: bool, const CORRELATION: bool> SymbolPair<RATIO, CORRELATION> {
61    /// A correlation over `length` values, raised to at least 2. Other studies of the pair
62    /// ignore it.
63    pub fn new(length: usize) -> Self {
64        Self {
65            symbols: [String::new(), String::new()],
66            sources: [PriceSource::Close; 2],
67            length: length.max(2),
68            log_returns: false,
69            pairs: Vec::new(),
70            values: IndicatorValues::new(),
71            color: if CORRELATION {
72                palette::LIGHT_BLUE
73            } else {
74                Color32::from_rgba_unmultiplied(128, 0, 128, 166)
75            },
76            visible: true,
77        }
78    }
79
80    /// Correlate log returns instead of the values.
81    pub fn with_log_returns(mut self, log_returns: bool) -> Self {
82        self.log_returns = log_returns;
83        self
84    }
85
86    /// The history a series needs from the host, or `None` for the chart symbol.
87    fn request(&self, leg: usize) -> Option<StudyDataRequest> {
88        (!self.symbols[leg].is_empty()).then(|| StudyDataRequest {
89            metric: None,
90            symbol: self.symbols[leg].clone(),
91            timeframe: None,
92        })
93    }
94
95    /// Pearson correlation of the series over the window ending at bar `at`.
96    fn correlation(&self, at: usize) -> Option<f64> {
97        let span = self.length.saturating_add(usize::from(self.log_returns));
98        let window = &self.pairs[(at + 1).checked_sub(span)?..=at];
99        let series = |leg: usize| -> Option<Vec<f64>> {
100            if self.log_returns {
101                window
102                    .windows(2)
103                    .map(|pair| {
104                        let (before, after) = (pair[0][leg], pair[1][leg]);
105                        let change = (after / before).ln();
106                        (before > 0.0 && after > 0.0 && change.is_finite()).then_some(change)
107                    })
108                    .collect()
109            } else {
110                window
111                    .iter()
112                    .map(|pair| pair[leg].is_finite().then_some(pair[leg]))
113                    .collect()
114            }
115        };
116        let (x, y) = (series(0)?, series(1)?);
117        // Constant values have no variance, though their mean may not be exact.
118        if x.iter().all(|v| *v == x[0]) || y.iter().all(|v| *v == y[0]) {
119            return None;
120        }
121        let n = x.len() as f64;
122        let (mean_x, mean_y) = (x.iter().sum::<f64>() / n, y.iter().sum::<f64>() / n);
123        let (mut xy, mut xx, mut yy) = (0.0, 0.0, 0.0);
124        for (x, y) in x.iter().zip(&y) {
125            let (dx, dy) = (x - mean_x, y - mean_y);
126            xy += dx * dy;
127            xx += dx * dx;
128            yy += dy * dy;
129        }
130        let correlation = xy / (xx * yy).sqrt();
131        correlation
132            .is_finite()
133            .then_some(correlation.clamp(-1.0, 1.0))
134    }
135}
136
137impl<const RATIO: bool, const CORRELATION: bool> Default for SymbolPair<RATIO, CORRELATION> {
138    fn default() -> Self {
139        Self::new(20)
140    }
141}
142
143impl<const RATIO: bool, const CORRELATION: bool> Indicator for SymbolPair<RATIO, CORRELATION> {
144    fn line_style(&self, _line: usize) -> crate::studies::IndicatorLineStyle {
145        crate::studies::IndicatorLineStyle {
146            width: if CORRELATION { 1.0 } else { 2.0 },
147            ..Default::default()
148        }
149    }
150
151    fn name(&self) -> &str {
152        if CORRELATION {
153            "Correlation Coefficient"
154        } else if RATIO {
155            "Ratio"
156        } else {
157            "Spread"
158        }
159    }
160
161    fn desc(&self) -> &str {
162        if CORRELATION {
163            "Correlation Coefficient - Rolling Pearson correlation of two aligned series"
164        } else if RATIO {
165            "Ratio - One aligned series divided by another"
166        } else {
167            "Spread - One aligned series minus another"
168        }
169    }
170
171    fn inputs(&self) -> Vec<StudyInput> {
172        let defaults = Self::default();
173        let mut inputs = Vec::new();
174        for (leg, label) in ["First", "Second"].into_iter().enumerate() {
175            let (symbol, source) = (format!("symbol_{leg}"), format!("source_{leg}"));
176            inputs.push(
177                StudyInput::new(
178                    symbol,
179                    format!("{label} symbol"),
180                    InputValue::Symbol(self.symbols[leg].clone()),
181                    InputKind::Symbol,
182                )
183                .with_default(InputValue::Symbol(defaults.symbols[leg].clone())),
184            );
185            let position = |source: PriceSource| {
186                PriceSource::all()
187                    .iter()
188                    .position(|candidate| *candidate == source)
189                    .unwrap_or(0)
190            };
191            inputs.push(
192                StudyInput::new(
193                    source,
194                    format!("{label} source"),
195                    InputValue::Choice(position(self.sources[leg])),
196                    InputKind::Choice(
197                        PriceSource::all()
198                            .iter()
199                            .map(|source| source.label().into())
200                            .collect(),
201                    ),
202                )
203                .with_default(InputValue::Choice(position(defaults.sources[leg]))),
204            );
205        }
206        if CORRELATION {
207            inputs.push(
208                StudyInput::new(
209                    "length",
210                    "Length",
211                    InputValue::Length(self.length),
212                    InputKind::Length(2, usize::MAX),
213                )
214                .with_default(InputValue::Length(defaults.length)),
215            );
216            inputs.push(
217                StudyInput::new(
218                    "log_returns",
219                    "Use log returns",
220                    InputValue::Toggle(self.log_returns),
221                    InputKind::Toggle,
222                )
223                .with_default(InputValue::Toggle(false)),
224            );
225        }
226        inputs
227    }
228
229    fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
230        let schema = self
231            .inputs()
232            .into_iter()
233            .find(|input| input.id == id)
234            .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
235        if !schema.accepts(&value) {
236            return Err(crate::studies::StudyError::invalid_input(id));
237        }
238        match (id, value) {
239            ("symbol_0", InputValue::Symbol(value)) => self.symbols[0] = value,
240            ("symbol_1", InputValue::Symbol(value)) => self.symbols[1] = value,
241            ("source_0", InputValue::Choice(value)) => self.sources[0] = PriceSource::all()[value],
242            ("source_1", InputValue::Choice(value)) => self.sources[1] = PriceSource::all()[value],
243            ("length", InputValue::Length(value)) => self.length = value,
244            ("log_returns", InputValue::Toggle(value)) => self.log_returns = value,
245            _ => return Err(crate::studies::StudyError::UnknownInput(id.into())),
246        }
247        self.pairs.clear();
248        self.values.clear();
249        Ok(())
250    }
251
252    fn data_requests(&self) -> Vec<StudyDataRequest> {
253        let mut requests: Vec<_> = (0..2).filter_map(|leg| self.request(leg)).collect();
254        requests.dedup();
255        requests
256    }
257
258    fn calculate(&mut self, data: &[Bar]) {
259        self.update(data, 0);
260    }
261
262    fn update(&mut self, data: &[Bar], from: usize) {
263        self.update_data(data, from, &[]);
264    }
265
266    fn update_data(&mut self, data: &[Bar], from: usize, sources: &[StudyData<'_>]) {
267        let from = from.min(self.values.len()).min(data.len());
268        self.pairs.truncate(from);
269        self.values.truncate(from);
270        // The chart's own bars, or the series of the host symbol; a series the host has not
271        // supplied has no bars.
272        let series: Vec<_> = (0..2)
273            .map(|leg| {
274                self.request(leg)
275                    .map(|request| sources.iter().find(|source| source.request == request))
276            })
277            .collect();
278        for bar in &data[from..] {
279            let pair: [f64; 2] = std::array::from_fn(|leg| {
280                let bar = match series[leg] {
281                    None => Some(bar),
282                    Some(source) => {
283                        source.and_then(|source| source.bar_at(bar.time).map(|at| &source.bars[at]))
284                    }
285                };
286                bar.map_or(f64::NAN, |bar| {
287                    self.sources[leg].compute(bar.open, bar.high, bar.low, bar.close)
288                })
289            });
290            self.pairs.push(pair);
291            let at = self.pairs.len() - 1;
292            let value = if CORRELATION {
293                self.correlation(at)
294            } else if pair.iter().all(|value| value.is_finite()) {
295                if RATIO {
296                    (pair[1] != 0.0).then(|| pair[0] / pair[1])
297                } else {
298                    Some(pair[0] - pair[1])
299                }
300            } else {
301                None
302            };
303            self.values.push_lines(&[value.unwrap_or(f64::NAN)]);
304        }
305    }
306
307    fn is_incremental(&self) -> bool {
308        true
309    }
310
311    fn values(&self) -> &IndicatorValues {
312        &self.values
313    }
314
315    fn colors(&self) -> Vec<Color32> {
316        vec![self.color]
317    }
318
319    fn set_colors(&mut self, colors: Vec<Color32>) {
320        if let Some(color) = colors.first() {
321            self.color = *color;
322        }
323    }
324
325    fn pane(&self) -> crate::studies::StudyPane {
326        crate::studies::StudyPane {
327            range: CORRELATION.then_some((-1.1, 1.1)),
328            levels: if CORRELATION {
329                vec![(0.0, palette::GRAY.gamma_multiply_u8(128), "Zero")]
330            } else {
331                Vec::new()
332            },
333            ..Default::default()
334        }
335    }
336
337    fn is_overlay(&self) -> bool {
338        false
339    }
340
341    fn is_visible(&self) -> bool {
342        self.visible
343    }
344
345    fn set_visible(&mut self, visible: bool) {
346        self.visible = visible;
347    }
348
349    fn clone_box(&self) -> Box<dyn Indicator> {
350        Box::new(self.clone())
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::model::{BarClock, Timeframe};
358    use chrono::{DateTime, Duration};
359
360    fn bars(values: &[Option<f64>]) -> Vec<Bar> {
361        values
362            .iter()
363            .enumerate()
364            .filter_map(|(at, value)| {
365                let value = (*value)?;
366                Some(Bar::new(
367                    DateTime::UNIX_EPOCH + Duration::days(at as i64),
368                    value,
369                    value,
370                    value,
371                    value,
372                    1.0,
373                ))
374            })
375            .collect()
376    }
377
378    fn run(
379        name: &str,
380        inputs: &[(&str, InputValue)],
381        chart: &[Option<f64>],
382        other: &[Option<f64>],
383    ) -> Vec<Option<f64>> {
384        let mut study = crate::studies::IndicatorFactory::new()
385            .create(name)
386            .unwrap();
387        study
388            .set_input("symbol_1", InputValue::Symbol("B".into()))
389            .unwrap();
390        for (id, value) in inputs {
391            study.set_input(id, value.clone()).unwrap();
392        }
393        let (chart, other) = (bars(chart), bars(other));
394        let sources: Vec<_> = study
395            .data_requests()
396            .into_iter()
397            .map(|request| StudyData {
398                request,
399                bars: &other,
400                clock: BarClock::new(Timeframe::Day1),
401                instrument: None,
402                timeframe: Timeframe::Day1,
403            })
404            .collect();
405        study.update_data(&chart, 0, &sources);
406        (0..chart.len())
407            .map(|at| study.values().value(0, at))
408            .collect()
409    }
410
411    fn near(actual: &[Option<f64>], expected: &[Option<f64>], case: &str) {
412        assert_eq!(actual.len(), expected.len(), "{case}");
413        for (at, (got, want)) in actual.iter().zip(expected).enumerate() {
414            match (got, want) {
415                (Some(got), Some(want)) => assert!((got - want).abs() < 1e-12, "{case} bar {at}"),
416                _ => assert_eq!(got, want, "{case} bar {at}"),
417            }
418        }
419    }
420
421    /// Hand-calculated two-source fixtures: the other series misses day 3 and holds a zero on
422    /// day 4, which leaves the ratio undefined there and the spread defined.
423    #[test]
424    fn arithmetic_aligns_by_timestamp_and_leaves_undefined_values_unavailable() {
425        let s = Some;
426        let chart = [s(8.0), s(9.0), s(-6.0), s(7.0), s(5.0), s(3.0)];
427        let other = [s(2.0), s(-3.0), s(4.0), None, s(0.0), s(1.5)];
428        near(
429            &run("Ratio", &[], &chart, &other),
430            &[s(4.0), s(-3.0), s(-1.5), None, None, s(2.0)],
431            "ratio",
432        );
433        near(
434            &run("Spread", &[], &chart, &other),
435            &[s(6.0), s(12.0), s(-10.0), None, s(5.0), s(1.5)],
436            "spread",
437        );
438        // The second source is the high, which equals the close here, and the first the open.
439        let mut study = crate::studies::IndicatorFactory::new()
440            .create("Spread")
441            .unwrap();
442        study.set_input("source_0", InputValue::Choice(0)).unwrap();
443        study.set_input("source_1", InputValue::Choice(1)).unwrap();
444        let bar = Bar::new(DateTime::UNIX_EPOCH, 10.0, 14.0, 9.0, 12.0, 1.0);
445        study.calculate(&[bar]);
446        assert_eq!(study.values().value(0, 0), Some(-4.0));
447    }
448
449    /// Windows of three values, by hand: x = 1 2 3 4 5 against y = 2 4 5 8 6 has
450    /// r = 3/sqrt(28/3), 4/sqrt(52/3) and 1/sqrt(28/3) (sums of products over the root of the sums of squares).
451    #[test]
452    fn correlation_uses_complete_windows_and_leaves_zero_variance_unavailable() {
453        let s = Some;
454        let x = [s(1.0), s(2.0), s(3.0), s(4.0), s(5.0)];
455        let y = [s(2.0), s(4.0), s(5.0), s(8.0), s(6.0)];
456        let length = [("length", InputValue::Length(3))];
457        near(
458            &run("Correlation Coefficient", &length, &x, &y),
459            &[
460                None,
461                None,
462                s(3.0 / (28.0f64 / 3.0).sqrt()),
463                s(4.0 / (52.0f64 / 3.0).sqrt()),
464                s(1.0 / (28.0f64 / 3.0).sqrt()),
465            ],
466            "positive",
467        );
468        let falling = [s(5.0), s(4.0), s(3.0), s(2.0), s(1.0)];
469        near(
470            &run("Correlation Coefficient", &length, &x, &falling),
471            &[None, None, s(-1.0), s(-1.0), s(-1.0)],
472            "negative",
473        );
474        // A missing day blanks the windows that hold it.
475        let gap = [s(2.0), s(4.0), None, s(8.0), s(6.0)];
476        near(
477            &run("Correlation Coefficient", &length, &x, &gap),
478            &[None; 5],
479            "missing",
480        );
481        // A constant series has no variance; the last window of y is 2, 2, 3, so r = 1/sqrt(4/3).
482        let flat = [s(2.0), s(2.0), s(2.0), s(2.0), s(3.0)];
483        near(
484            &run("Correlation Coefficient", &length, &x, &flat),
485            &[None, None, None, None, s(0.75f64.sqrt())],
486            "undefined",
487        );
488    }
489
490    /// Log returns: doubling and tripling twice then halving and thirding give returns
491    /// (ln 2, ln 2, -ln 2) and (ln 3, ln 3, -ln 3), perfectly correlated. Negative prices
492    /// remain unavailable even when their ratios are positive and their logs finite.
493    #[test]
494    fn log_return_correlation_needs_positive_prices() {
495        let s = Some;
496        let inputs = [
497            ("length", InputValue::Length(3)),
498            ("log_returns", InputValue::Toggle(true)),
499        ];
500        let a = [s(1.0), s(2.0), s(4.0), s(2.0)];
501        let b = [s(1.0), s(3.0), s(9.0), s(3.0)];
502        near(
503            &run("Correlation Coefficient", &inputs, &a, &b),
504            &[None, None, None, s(1.0)],
505            "returns",
506        );
507        let zero = [s(1.0), s(0.0), s(9.0), s(3.0)];
508        near(
509            &run("Correlation Coefficient", &inputs, &a, &zero),
510            &[None; 4],
511            "zero",
512        );
513        let negative = [s(-1.0), s(-2.0), s(-4.0), s(-2.0)];
514        for (a, b) in [(&negative, &b), (&a, &negative)] {
515            near(
516                &run("Correlation Coefficient", &inputs, a, b),
517                &[None; 4],
518                "negative",
519            );
520        }
521    }
522}