Skip to main content

wickra_core/indicators/
kendall_tau.rs

1//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// `+1` / `0` / `-1` sign of `a − b`.
9fn sign(a: f64, b: f64) -> i32 {
10    if a > b {
11        1
12    } else if a < b {
13        -1
14    } else {
15        0
16    }
17}
18
19/// Kendall's tau-b — a rank correlation between two synchronised series based on
20/// the balance of **concordant** and **discordant** pairs, with a tie correction.
21///
22/// ```text
23/// over all pairs (i < j) in the window:
24///   concordant if (x_j − x_i) and (y_j − y_i) share a sign
25///   discordant if they have opposite signs
26///   tie_x / tie_y if the respective difference is zero
27/// n0  = N(N−1)/2
28/// tau_b = (n_concordant − n_discordant) / sqrt((n0 − tie_x)(n0 − tie_y))
29/// ```
30///
31/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear*
32/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates
33/// ranks via their differences, Kendall's tau counts how often the two series move
34/// the **same direction** between every pair of observations. It is the most
35/// robust of the three to outliers and to non-linear-but-monotonic
36/// relationships, and the tau-b form corrects for ties so repeated values do not
37/// bias it. The output is in `[−1, +1]`: `+1` perfectly concordant, `−1`
38/// perfectly discordant, `0` no monotonic association.
39///
40/// The window holds the last `period` pairs and is recomputed each bar in
41/// O(`period²`). A window with no untied pairs on one side returns `0`. The first
42/// value lands after `period` inputs.
43///
44/// # Example
45///
46/// ```
47/// use wickra_core::{Indicator, KendallTau};
48///
49/// let mut indicator = KendallTau::new(20).unwrap();
50/// let mut last = None;
51/// for i in 0..40 {
52///     let x = f64::from(i);
53///     last = indicator.update((x, 2.0 * x)); // perfectly concordant
54/// }
55/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
56/// ```
57#[derive(Debug, Clone)]
58pub struct KendallTau {
59    period: usize,
60    window: VecDeque<(f64, f64)>,
61    /// Reusable scratch buffer to avoid allocating per `update`.
62    scratch: Vec<(f64, f64)>,
63    last: Option<f64>,
64}
65
66impl KendallTau {
67    /// Construct a rolling Kendall's tau-b over `period` pairs.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at
72    /// least two pairs).
73    pub fn new(period: usize) -> Result<Self> {
74        if period < 2 {
75            return Err(Error::InvalidPeriod {
76                message: "Kendall tau needs period >= 2",
77            });
78        }
79        if period > crate::error::MAX_PERIOD {
80            return Err(Error::InvalidPeriod {
81                message: crate::error::PERIOD_ABOVE_MAX,
82            });
83        }
84        Ok(Self {
85            period,
86            window: VecDeque::with_capacity(period),
87            scratch: Vec::with_capacity(period),
88            last: None,
89        })
90    }
91
92    /// Configured window of pairs.
93    pub const fn period(&self) -> usize {
94        self.period
95    }
96
97    /// Current value if available.
98    pub const fn value(&self) -> Option<f64> {
99        self.last
100    }
101
102    fn compute(&mut self) -> f64 {
103        self.scratch.clear();
104        self.scratch.extend(self.window.iter().copied());
105        let pairs = &self.scratch;
106        let len = pairs.len();
107        let mut concordant: i64 = 0;
108        let mut discordant: i64 = 0;
109        let mut tie_x: i64 = 0;
110        let mut tie_y: i64 = 0;
111        for i in 0..len {
112            for j in (i + 1)..len {
113                let sx = sign(pairs[j].0, pairs[i].0);
114                let sy = sign(pairs[j].1, pairs[i].1);
115                if sx == 0 {
116                    tie_x += 1;
117                }
118                if sy == 0 {
119                    tie_y += 1;
120                }
121                let prod = sx * sy;
122                if prod > 0 {
123                    concordant += 1;
124                } else if prod < 0 {
125                    discordant += 1;
126                }
127            }
128        }
129        let n0 = (len * (len - 1) / 2) as f64;
130        let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt();
131        if denom == 0.0 {
132            return 0.0;
133        }
134        ((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0)
135    }
136}
137
138impl Indicator for KendallTau {
139    type Input = (f64, f64);
140    type Output = f64;
141
142    #[inline]
143    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
144        if !input.0.is_finite() || !input.1.is_finite() {
145            return None;
146        }
147        if self.window.len() == self.period {
148            self.window.pop_front();
149        }
150        self.window.push_back(input);
151        if self.window.len() < self.period {
152            return None;
153        }
154        let out = self.compute();
155        self.last = Some(out);
156        Some(out)
157    }
158
159    fn reset(&mut self) {
160        self.window.clear();
161        self.scratch.clear();
162        self.last = None;
163    }
164
165    #[inline]
166    fn warmup_period(&self) -> usize {
167        self.period
168    }
169
170    #[inline]
171    fn is_ready(&self) -> bool {
172        self.last.is_some()
173    }
174
175    #[inline]
176    fn name(&self) -> &'static str {
177        "KendallTau"
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::traits::BatchExt;
185    use approx::assert_relative_eq;
186
187    #[test]
188    fn rejects_period_below_two() {
189        assert!(matches!(
190            KendallTau::new(1),
191            Err(Error::InvalidPeriod { .. })
192        ));
193        assert!(KendallTau::new(2).is_ok());
194    }
195
196    #[test]
197    fn accessors_and_metadata() {
198        let k = KendallTau::new(20).unwrap();
199        assert_eq!(k.period(), 20);
200        assert_eq!(k.warmup_period(), 20);
201        assert_eq!(k.name(), "KendallTau");
202        assert!(!k.is_ready());
203        assert_eq!(k.value(), None);
204    }
205
206    #[test]
207    fn first_emission_at_warmup_period() {
208        let mut k = KendallTau::new(4).unwrap();
209        let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
210        for v in out.iter().take(3) {
211            assert!(v.is_none());
212        }
213        assert!(out[3].is_some());
214    }
215
216    #[test]
217    fn monotone_increasing_is_one() {
218        let pairs: Vec<(f64, f64)> = (0..20)
219            .map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0))
220            .collect();
221        let last = KendallTau::new(10)
222            .unwrap()
223            .batch(&pairs)
224            .into_iter()
225            .flatten()
226            .last()
227            .unwrap();
228        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
229    }
230
231    #[test]
232    fn monotone_decreasing_is_minus_one() {
233        let pairs: Vec<(f64, f64)> = (0..20)
234            .map(|i| (f64::from(i), -3.0 * f64::from(i)))
235            .collect();
236        let last = KendallTau::new(10)
237            .unwrap()
238            .batch(&pairs)
239            .into_iter()
240            .flatten()
241            .last()
242            .unwrap();
243        assert_relative_eq!(last, -1.0, epsilon = 1e-9);
244    }
245
246    #[test]
247    fn constant_channel_yields_zero() {
248        // y constant -> every y-difference is a tie -> denom 0 -> 0.
249        let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
250        let last = KendallTau::new(8)
251            .unwrap()
252            .batch(&pairs)
253            .into_iter()
254            .flatten()
255            .last()
256            .unwrap();
257        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
258    }
259
260    #[test]
261    fn output_in_range() {
262        let pairs: Vec<(f64, f64)> = (0..80)
263            .map(|i| {
264                let t = f64::from(i);
265                (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
266            })
267            .collect();
268        for v in KendallTau::new(20)
269            .unwrap()
270            .batch(&pairs)
271            .into_iter()
272            .flatten()
273        {
274            assert!((-1.0..=1.0).contains(&v));
275        }
276    }
277
278    #[test]
279    fn reset_clears_state() {
280        let mut k = KendallTau::new(4).unwrap();
281        k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]);
282        assert!(k.is_ready());
283        k.reset();
284        assert!(!k.is_ready());
285        assert_eq!(k.value(), None);
286        assert_eq!(k.update((1.0, 1.0)), None);
287    }
288
289    #[test]
290    fn batch_equals_streaming() {
291        let pairs: Vec<(f64, f64)> = (0..60)
292            .map(|i| {
293                let t = f64::from(i);
294                (t.sin(), (t * 0.5).cos())
295            })
296            .collect();
297        let batch = KendallTau::new(14).unwrap().batch(&pairs);
298        let mut b = KendallTau::new(14).unwrap();
299        let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
300        assert_eq!(batch, streamed);
301    }
302
303    #[test]
304    fn ties_are_corrected() {
305        // Tied x values (points 0 and 1) and tied y values (points 1 and 2)
306        // exercise the tie_x / tie_y correction counters.
307        let mut k = KendallTau::new(4).unwrap();
308        assert_eq!(k.update((1.0, 1.0)), None);
309        assert_eq!(k.update((1.0, 2.0)), None);
310        assert_eq!(k.update((2.0, 2.0)), None);
311        let v = k.update((3.0, 3.0)).unwrap();
312        assert!((-1.0..=1.0).contains(&v), "got {v}");
313    }
314
315    #[test]
316    fn non_finite_input_returns_none() {
317        let mut k = KendallTau::new(2).unwrap();
318        assert_eq!(k.update((f64::NAN, 1.0)), None);
319        assert_eq!(k.update((1.0, f64::INFINITY)), None);
320        // The rejected ticks leave no trace: a fresh window still warms up.
321        assert_eq!(k.update((1.0, 2.0)), None);
322        assert!(k.update((2.0, 5.0)).is_some());
323    }
324}