1use crate::model::{Bar, PriceSource};
31use crate::studies::palette;
32use crate::studies::{
33 Indicator, IndicatorValues, InputKind, InputValue, StudyData, StudyDataRequest, StudyInput,
34};
35use egui::Color32;
36
37pub type Ratio = SymbolPair<true, false>;
39pub type Spread = SymbolPair<false, false>;
41pub type Correlation = SymbolPair<false, true>;
43
44#[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 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 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 pub fn with_log_returns(mut self, log_returns: bool) -> Self {
82 self.log_returns = log_returns;
83 self
84 }
85
86 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 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 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 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 #[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 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 #[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 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 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 #[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}