Skip to main content

volas_compute/
window.rs

1//! pandas-semantics windowed aggregations — the single kernel source for the
2//! python `rolling` / `expanding` / `ewm` API surface.
3//!
4//! Distinct from [`crate::indicators`] (TA-Lib semantics: a NaN poisons its
5//! window): here `NaN` means *missing* and is skipped, and a row emits a value
6//! once the window holds at least `min_periods` present values — exactly
7//! pandas's window model. `window == usize::MAX` is an expanding window.
8//!
9//! All kernels take `&[f64]` (the python layer funnels int/bool, and routes the
10//! dtype-preserving members `first` / `last` / `count` / `nunique` through the
11//! position / count kernels instead).
12
13use std::collections::HashMap;
14
15/// The trailing window over `i`: `[start, i]`.
16#[inline]
17fn win_start(i: usize, window: usize) -> usize {
18    (i + 1).saturating_sub(window)
19}
20
21/// Generic per-window fold over the PRESENT values (reusable buffer): the
22/// simple, numerically transparent path used by the order / moment statistics.
23fn per_window(
24    data: &[f64],
25    window: usize,
26    min_periods: usize,
27    mut f: impl FnMut(&[f64]) -> f64,
28) -> Vec<f64> {
29    let n = data.len();
30    let mut out = vec![f64::NAN; n];
31    let mut buf: Vec<f64> = Vec::with_capacity(window.min(n));
32    for i in 0..n {
33        buf.clear();
34        for &x in &data[win_start(i, window)..=i] {
35            if !x.is_nan() {
36                buf.push(x);
37            }
38        }
39        if buf.len() >= min_periods.max(1) {
40            out[i] = f(&buf);
41        }
42    }
43    out
44}
45
46// --- count / sum / mean (sliding O(n)) ---------------------------------------
47
48/// Present-value count per window (pandas `count`). pandas implements count
49/// as `notna().rolling(...).sum()` over the DENSE 0/1 mask, so `min_periods`
50/// gates on the number of rows the window COVERS (NaN rows included), not on
51/// the present count — a window full of NaN emits `0`, a not-yet-full head
52/// window emits NaN. `center=true` uses the clipped centered bounds.
53pub fn count(
54    data: &[f64],
55    window: usize,
56    min_periods: usize,
57    center: bool,
58) -> Vec<f64> {
59    let n = data.len();
60    let fwd = if center { window - window / 2 - 1 } else { 0 };
61    let back = window.saturating_sub(fwd + 1);
62    (0..n)
63        .map(|i| {
64            let end = std::cmp::min(i + fwd, n - 1);
65            let start = if window == usize::MAX { 0 } else { i.saturating_sub(back) };
66            let covered = end - start + 1;
67            if covered < min_periods.max(1) {
68                return f64::NAN;
69            }
70            data[start..=end].iter().filter(|x| !x.is_nan()).count() as f64
71        })
72        .collect()
73}
74
75/// Drive a leaving/entering sliding scan; returns the per-row state snapshots.
76fn sliding<T: Copy>(
77    data: &[f64],
78    window: usize,
79    mut update: impl FnMut(f64, bool) -> T,
80) -> Vec<T> {
81    (0..data.len())
82        .map(|i| {
83            if window != usize::MAX && i >= window {
84                update(data[i - window], false);
85            }
86            update(data[i], true)
87        })
88        .collect()
89}
90
91/// Rolling / expanding sum of the present values (pandas `sum`).
92pub fn sum(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
93    let mut s = 0.0f64;
94    let mut nobs = 0usize;
95    sliding(data, window, |x, entering| {
96        if !x.is_nan() {
97            if entering {
98                s += x;
99                nobs += 1;
100            } else {
101                s -= x;
102                nobs -= 1;
103            }
104        }
105        (s, nobs)
106    })
107    .into_iter()
108    .map(|(s, c)| if c >= min_periods.max(1) { s } else { f64::NAN })
109    .collect()
110}
111
112/// Rolling / expanding mean of the present values (pandas `mean`).
113pub fn mean(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
114    let mut s = 0.0f64;
115    let mut nobs = 0usize;
116    sliding(data, window, |x, entering| {
117        if !x.is_nan() {
118            if entering {
119                s += x;
120                nobs += 1;
121            } else {
122                s -= x;
123                nobs -= 1;
124            }
125        }
126        (s, nobs)
127    })
128    .into_iter()
129    .map(|(s, c)| {
130        if c >= min_periods.max(1) {
131            s / c as f64
132        } else {
133            f64::NAN
134        }
135    })
136    .collect()
137}
138
139// --- min / max (monotonic deque, O(n)) ---------------------------------------
140
141fn extremum(data: &[f64], window: usize, min_periods: usize, is_min: bool) -> Vec<f64> {
142    let n = data.len();
143    let mut out = vec![f64::NAN; n];
144    let mut deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
145    let better = |a: f64, b: f64| if is_min { a <= b } else { a >= b };
146    let mut nobs = 0usize;
147    for i in 0..n {
148        if window != usize::MAX && i >= window && !data[i - window].is_nan() {
149            nobs -= 1;
150        }
151        if let Some(&front) = deque.front() {
152            if window != usize::MAX && front + window <= i {
153                deque.pop_front();
154            }
155        }
156        if !data[i].is_nan() {
157            nobs += 1;
158            while let Some(&back) = deque.back() {
159                if better(data[i], data[back]) {
160                    deque.pop_back();
161                } else {
162                    break;
163                }
164            }
165            deque.push_back(i);
166        }
167        if nobs >= min_periods.max(1) {
168            if let Some(&front) = deque.front() {
169                out[i] = data[front];
170            }
171        }
172    }
173    out
174}
175
176/// Rolling / expanding minimum (pandas `min`).
177pub fn min(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
178    extremum(data, window, min_periods, true)
179}
180
181/// Rolling / expanding maximum (pandas `max`).
182pub fn max(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
183    extremum(data, window, min_periods, false)
184}
185
186// --- dispersion / moments -----------------------------------------------------
187
188fn buf_var(b: &[f64], ddof: usize) -> f64 {
189    if b.len() <= ddof {
190        return f64::NAN;
191    }
192    let m = b.iter().sum::<f64>() / b.len() as f64;
193    b.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / (b.len() - ddof) as f64
194}
195
196/// Rolling / expanding sample variance (pandas `var`, default ddof=1).
197/// Per-window two-pass — numerically stable, no sliding cancellation.
198pub fn var(data: &[f64], window: usize, min_periods: usize, ddof: usize) -> Vec<f64> {
199    per_window(data, window, min_periods, |b| buf_var(b, ddof))
200}
201
202/// Rolling / expanding standard deviation (pandas `std`).
203pub fn std(data: &[f64], window: usize, min_periods: usize, ddof: usize) -> Vec<f64> {
204    per_window(data, window, min_periods, |b| buf_var(b, ddof).sqrt())
205}
206
207/// Standard error of the mean (pandas `sem`): `std(ddof) / sqrt(count)`.
208pub fn sem(data: &[f64], window: usize, min_periods: usize, ddof: usize) -> Vec<f64> {
209    per_window(data, window, min_periods, |b| {
210        (buf_var(b, ddof) / b.len() as f64).sqrt()
211    })
212}
213
214/// Mean absolute deviation about the window mean (TradingView `ta.dev`):
215/// `mean(|x - mean(x)|)` over the present values — the dispersion measure CCI
216/// is built on, and the one statistic missing from var/std/sem.
217pub fn dev(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
218    per_window(data, window, min_periods, |b| {
219        let m = b.iter().sum::<f64>() / b.len() as f64;
220        b.iter().map(|x| (x - m).abs()).sum::<f64>() / b.len() as f64
221    })
222}
223
224/// Rolling mode (TradingView `ta.mode`): the most frequent present value; ties
225/// resolve to the SMALLEST value. NA cells are ignored (matching Pine).
226pub fn mode(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
227    per_sorted_window(data, window, min_periods, |sorted, _| {
228        // `sorted` is ascending, so equal values are contiguous: scan runs and
229        // keep the value with the longest run; on a tie the earlier (smaller)
230        // value is already held, giving "ties -> smallest" for free.
231        let (mut best_val, mut best_len) = (sorted[0], 1usize);
232        let (mut cur_val, mut cur_len) = (sorted[0], 1usize);
233        for &x in &sorted[1..] {
234            if x == cur_val {
235                cur_len += 1;
236            } else {
237                cur_val = x;
238                cur_len = 1;
239            }
240            if cur_len > best_len {
241                best_len = cur_len;
242                best_val = cur_val;
243            }
244        }
245        best_val
246    })
247}
248
249/// Bias-corrected sample skewness (pandas `skew`; needs >= 3 present values).
250pub fn skew(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
251    per_window(data, window, min_periods, |b| {
252        let n = b.len() as f64;
253        if b.len() < 3 {
254            return f64::NAN;
255        }
256        let m = b.iter().sum::<f64>() / n;
257        let m2 = b.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n;
258        let m3 = b.iter().map(|x| (x - m).powi(3)).sum::<f64>() / n;
259        if m2 <= 0.0 {
260            return f64::NAN;
261        }
262        (n * (n - 1.0)).sqrt() / (n - 2.0) * m3 / m2.powf(1.5)
263    })
264}
265
266/// Bias-corrected excess kurtosis (pandas `kurt`; needs >= 4 present values).
267pub fn kurt(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
268    per_window(data, window, min_periods, |b| {
269        let n = b.len() as f64;
270        if b.len() < 4 {
271            return f64::NAN;
272        }
273        let m = b.iter().sum::<f64>() / n;
274        let m2 = b.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n;
275        let m4 = b.iter().map(|x| (x - m).powi(4)).sum::<f64>() / n;
276        if m2 <= 0.0 {
277            return f64::NAN;
278        }
279        ((n + 1.0) * m4 / (m2 * m2) - 3.0 * (n - 1.0)) * (n - 1.0)
280            / ((n - 2.0) * (n - 3.0))
281    })
282}
283
284// --- order statistics ----------------------------------------------------------
285
286/// `q`-quantile of a SORTED buffer under a pandas interpolation mode.
287fn sorted_quantile(sorted: &[f64], q: f64, interpolation: &str) -> f64 {
288    let n = sorted.len();
289    let pos = q * (n - 1) as f64;
290    let (lo, hi) = (pos.floor() as usize, pos.ceil() as usize);
291    let frac = pos - lo as f64;
292    match interpolation {
293        "lower" => sorted[lo],
294        "higher" => sorted[hi],
295        // a .5 tie rounds to the EVEN index (pandas/numpy 'nearest')
296        "nearest" => sorted[if frac > 0.5 || (frac == 0.5 && lo % 2 == 1) { hi } else { lo }],
297        "midpoint" => (sorted[lo] + sorted[hi]) / 2.0,
298        // "linear" (validated by the caller)
299        _ => sorted[lo] + frac * (sorted[hi] - sorted[lo]),
300    }
301}
302
303fn per_sorted_window(
304    data: &[f64],
305    window: usize,
306    min_periods: usize,
307    mut f: impl FnMut(&[f64], f64) -> f64,
308) -> Vec<f64> {
309    let n = data.len();
310    let mut out = vec![f64::NAN; n];
311    // a maintained sorted multiset of the window's present values
312    let mut sorted: Vec<f64> = Vec::with_capacity(window.min(n));
313    for i in 0..n {
314        if window != usize::MAX && i >= window {
315            let leaving = data[i - window];
316            if !leaving.is_nan() {
317                let pos = sorted.partition_point(|&v| v < leaving);
318                sorted.remove(pos);
319            }
320        }
321        let x = data[i];
322        if !x.is_nan() {
323            let pos = sorted.partition_point(|&v| v < x);
324            sorted.insert(pos, x);
325        }
326        if sorted.len() >= min_periods.max(1) {
327            out[i] = f(&sorted, x);
328        }
329    }
330    out
331}
332
333/// Rolling / expanding median (pandas `median`).
334pub fn median(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
335    per_sorted_window(data, window, min_periods, |s, _| {
336        sorted_quantile(s, 0.5, "linear")
337    })
338}
339
340/// Rolling / expanding quantile (pandas `quantile(q, interpolation)`).
341pub fn quantile(
342    data: &[f64],
343    window: usize,
344    min_periods: usize,
345    q: f64,
346    interpolation: &str,
347) -> Vec<f64> {
348    per_sorted_window(data, window, min_periods, |s, _| {
349        sorted_quantile(s, q, interpolation)
350    })
351}
352
353/// Rank of the CURRENT row's value within its window (pandas `rank`):
354/// `method` is `average` / `min` / `max`, ties handled accordingly; `pct`
355/// scales by the present count; a missing current value stays NaN.
356pub fn rank(
357    data: &[f64],
358    window: usize,
359    min_periods: usize,
360    method: &str,
361    ascending: bool,
362    pct: bool,
363) -> Vec<f64> {
364    per_sorted_window(data, window, min_periods, |s, x| {
365        if x.is_nan() {
366            return f64::NAN;
367        }
368        let below = s.partition_point(|&v| v < x);
369        let through = s.partition_point(|&v| v <= x);
370        let (lo, hi) = if ascending {
371            (below + 1, through)
372        } else {
373            (s.len() - through + 1, s.len() - below)
374        };
375        let r = match method {
376            "min" => lo as f64,
377            "max" => hi as f64,
378            _ => (lo + hi) as f64 / 2.0, // "average" (validated by the caller)
379        };
380        if pct {
381            r / s.len() as f64
382        } else {
383            r
384        }
385    })
386}
387
388/// Count of distinct present values per window (pandas `nunique`).
389pub fn nunique(data: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
390    let n = data.len();
391    let mut out = vec![f64::NAN; n];
392    let mut counts: HashMap<u64, usize> = HashMap::new();
393    let mut nobs = 0usize;
394    for i in 0..n {
395        if window != usize::MAX && i >= window {
396            let leaving = data[i - window];
397            if !leaving.is_nan() {
398                nobs -= 1;
399                let k = leaving.to_bits();
400                let c = counts.get_mut(&k).expect("leaving value was inserted");
401                *c -= 1;
402                if *c == 0 {
403                    counts.remove(&k);
404                }
405            }
406        }
407        let x = data[i];
408        if !x.is_nan() {
409            nobs += 1;
410            *counts.entry(x.to_bits()).or_insert(0) += 1;
411        }
412        if nobs >= min_periods.max(1) {
413            out[i] = counts.len() as f64;
414        }
415    }
416    out
417}
418
419/// Position of the first / last PRESENT value in each window (`None` below
420/// min_periods or for an all-missing window). The caller gathers from the
421/// original column, so `first` / `last` preserve the source dtype.
422pub fn edge_positions(
423    valid: &[bool],
424    window: usize,
425    min_periods: usize,
426    last: bool,
427) -> Vec<Option<usize>> {
428    let n = valid.len();
429    let mut out = vec![None; n];
430    for i in 0..n {
431        let start = win_start(i, window);
432        let nobs = valid[start..=i].iter().filter(|&&v| v).count();
433        if nobs >= min_periods.max(1) {
434            out[i] = if last {
435                (start..=i).rev().find(|&k| valid[k])
436            } else {
437                (start..=i).find(|&k| valid[k])
438            };
439        }
440    }
441    out
442}
443
444// --- two-series ---------------------------------------------------------------
445
446fn pairwise(
447    x: &[f64],
448    y: &[f64],
449    window: usize,
450    min_periods: usize,
451    mut f: impl FnMut(&[f64], &[f64]) -> f64,
452) -> Vec<f64> {
453    let n = x.len();
454    let mut out = vec![f64::NAN; n];
455    let mut bx: Vec<f64> = Vec::new();
456    let mut by: Vec<f64> = Vec::new();
457    #[allow(clippy::needless_range_loop)] // numeric kernel: index-loop kept for hot-path codegen stability
458    for i in 0..n {
459        bx.clear();
460        by.clear();
461        for k in win_start(i, window)..=i {
462            // pandas pairwise rule: a row counts only when BOTH sides are present
463            if !x[k].is_nan() && !y[k].is_nan() {
464                bx.push(x[k]);
465                by.push(y[k]);
466            }
467        }
468        if bx.len() >= min_periods.max(1) {
469            out[i] = f(&bx, &by);
470        }
471    }
472    out
473}
474
475/// Rolling / expanding sample covariance of two series (pandas `cov`).
476pub fn cov(x: &[f64], y: &[f64], window: usize, min_periods: usize, ddof: usize) -> Vec<f64> {
477    pairwise(x, y, window, min_periods, |bx, by| {
478        if bx.len() <= ddof {
479            return f64::NAN;
480        }
481        let n = bx.len() as f64;
482        let (mx, my) = (
483            bx.iter().sum::<f64>() / n,
484            by.iter().sum::<f64>() / n,
485        );
486        bx.iter()
487            .zip(by)
488            .map(|(a, b)| (a - mx) * (b - my))
489            .sum::<f64>()
490            / (n - ddof as f64)
491    })
492}
493
494/// Rolling / expanding Pearson correlation of two series (pandas `corr`).
495pub fn corr(x: &[f64], y: &[f64], window: usize, min_periods: usize) -> Vec<f64> {
496    pairwise(x, y, window, min_periods, |bx, by| {
497        let sx = buf_var(bx, 1).sqrt();
498        let sy = buf_var(by, 1).sqrt();
499        // A zero *or NaN* std has no correlation; the negated form catches both.
500        #[allow(clippy::neg_cmp_op_on_partial_ord)]
501        if !(sx > 0.0) || !(sy > 0.0) {
502            return f64::NAN;
503        }
504        let n = bx.len() as f64;
505        let (mx, my) = (
506            bx.iter().sum::<f64>() / n,
507            by.iter().sum::<f64>() / n,
508        );
509        let c = bx
510            .iter()
511            .zip(by)
512            .map(|(a, b)| (a - mx) * (b - my))
513            .sum::<f64>()
514            / (n - 1.0);
515        c / (sx * sy)
516    })
517}
518
519// --- exponentially weighted ----------------------------------------------------
520
521/// pandas `ewm(...).mean()` — both `adjust` modes, both `ignore_na` modes
522/// (ports the pandas `ewm` kernel: with `ignore_na=False` the old weight keeps
523/// decaying across a missing row; with `True` a gap is invisible).
524pub fn ewm_mean(
525    data: &[f64],
526    alpha: f64,
527    adjust: bool,
528    ignore_na: bool,
529    min_periods: usize,
530) -> Vec<f64> {
531    let old_wt_factor = 1.0 - alpha;
532    let new_wt = if adjust { 1.0 } else { alpha };
533    let mut avg = f64::NAN;
534    let mut old_wt = 1.0;
535    let mut nobs = 0usize;
536    data.iter()
537        .map(|&x| {
538            let is_obs = !x.is_nan();
539            nobs += is_obs as usize;
540            if !avg.is_nan() {
541                if is_obs || !ignore_na {
542                    old_wt *= old_wt_factor;
543                    if is_obs {
544                        if avg != x {
545                            avg = (old_wt * avg + new_wt * x) / (old_wt + new_wt);
546                        }
547                        if adjust {
548                            old_wt += new_wt;
549                        } else {
550                            old_wt = 1.0;
551                        }
552                    }
553                }
554            } else if is_obs {
555                avg = x;
556            }
557            if nobs >= min_periods.max(1) {
558                avg
559            } else {
560                f64::NAN
561            }
562        })
563        .collect()
564}
565
566/// pandas `ewm(...).sum()` — the un-normalized weighted sum
567/// (`adjust=True` only; pandas raises for `adjust=False`, and so does the caller).
568pub fn ewm_sum(data: &[f64], alpha: f64, ignore_na: bool, min_periods: usize) -> Vec<f64> {
569    let old_wt_factor = 1.0 - alpha;
570    let mut s = f64::NAN;
571    let mut nobs = 0usize;
572    data.iter()
573        .map(|&x| {
574            let is_obs = !x.is_nan();
575            nobs += is_obs as usize;
576            if !s.is_nan() {
577                if is_obs || !ignore_na {
578                    s *= old_wt_factor;
579                    if is_obs {
580                        s += x;
581                    }
582                }
583            } else if is_obs {
584                s = x;
585            }
586            if nobs >= min_periods.max(1) {
587                s
588            } else {
589                f64::NAN
590            }
591        })
592        .collect()
593}
594
595/// pandas `ewm(...).cov(other)` kernel — also the engine behind `var` / `std`
596/// (`y = x`) and `corr`. `bias=false` applies pandas's debiasing factor.
597#[allow(clippy::too_many_arguments)]
598pub fn ewm_cov(
599    x: &[f64],
600    y: &[f64],
601    alpha: f64,
602    adjust: bool,
603    ignore_na: bool,
604    bias: bool,
605    min_periods: usize,
606) -> Vec<f64> {
607    let old_wt_factor = 1.0 - alpha;
608    let new_wt = if adjust { 1.0 } else { alpha };
609    let (mut mean_x, mut mean_y) = (f64::NAN, f64::NAN);
610    let mut cov = 0.0;
611    let (mut sum_wt, mut sum_wt2, mut old_wt) = (1.0, 1.0, 1.0);
612    let mut nobs = 0usize;
613    x.iter()
614        .zip(y)
615        .map(|(&xi, &yi)| {
616            let is_obs = !xi.is_nan() && !yi.is_nan();
617            nobs += is_obs as usize;
618            if !mean_x.is_nan() {
619                if is_obs || !ignore_na {
620                    sum_wt *= old_wt_factor;
621                    sum_wt2 *= old_wt_factor * old_wt_factor;
622                    old_wt *= old_wt_factor;
623                    if is_obs {
624                        let (old_mean_x, old_mean_y) = (mean_x, mean_y);
625                        if mean_x != xi {
626                            mean_x = (old_wt * old_mean_x + new_wt * xi) / (old_wt + new_wt);
627                        }
628                        if mean_y != yi {
629                            mean_y = (old_wt * old_mean_y + new_wt * yi) / (old_wt + new_wt);
630                        }
631                        cov = (old_wt
632                            * (cov + (old_mean_x - mean_x) * (old_mean_y - mean_y))
633                            + new_wt * (xi - mean_x) * (yi - mean_y))
634                            / (old_wt + new_wt);
635                        sum_wt += new_wt;
636                        sum_wt2 += new_wt * new_wt;
637                        old_wt += new_wt;
638                        if !adjust {
639                            sum_wt /= old_wt;
640                            sum_wt2 /= old_wt * old_wt;
641                            old_wt = 1.0;
642                        }
643                    }
644                }
645            } else if is_obs {
646                mean_x = xi;
647                mean_y = yi;
648            }
649            if nobs >= min_periods.max(1) {
650                if bias {
651                    cov
652                } else {
653                    let numerator = sum_wt * sum_wt;
654                    let denominator = numerator - sum_wt2;
655                    if denominator > 0.0 {
656                        (numerator / denominator) * cov
657                    } else {
658                        f64::NAN
659                    }
660                }
661            } else {
662                f64::NAN
663            }
664        })
665        .collect()
666}
667
668/// pandas `ewm(...).var(bias)` — `ewm_cov(x, x)`.
669pub fn ewm_var(
670    data: &[f64],
671    alpha: f64,
672    adjust: bool,
673    ignore_na: bool,
674    bias: bool,
675    min_periods: usize,
676) -> Vec<f64> {
677    ewm_cov(data, data, alpha, adjust, ignore_na, bias, min_periods)
678}
679
680/// pandas `ewm(...).corr(other)`: `cov / sqrt(var_x · var_y)` with the biased
681/// (bias=true) building blocks, exactly as pandas computes it.
682pub fn ewm_corr(
683    x: &[f64],
684    y: &[f64],
685    alpha: f64,
686    adjust: bool,
687    ignore_na: bool,
688    min_periods: usize,
689) -> Vec<f64> {
690    let cv = ewm_cov(x, y, alpha, adjust, ignore_na, true, min_periods);
691    // pandas masks each side to the PAIRWISE-present rows before the marginal
692    // variances, so a row missing in `y` does not perturb `var(x)`.
693    let n = x.len();
694    let (mut xp, mut yp) = (vec![f64::NAN; n], vec![f64::NAN; n]);
695    for i in 0..n {
696        if !x[i].is_nan() && !y[i].is_nan() {
697            xp[i] = x[i];
698            yp[i] = y[i];
699        }
700    }
701    let vx = ewm_cov(&xp, &xp, alpha, adjust, ignore_na, true, min_periods);
702    let vy = ewm_cov(&yp, &yp, alpha, adjust, ignore_na, true, min_periods);
703    cv.iter()
704        .zip(vx.iter().zip(&vy))
705        .map(|(&c, (&a, &b))| {
706            let d = (a * b).sqrt();
707            if d > 0.0 {
708                c / d
709            } else {
710                f64::NAN
711            }
712        })
713        .collect()
714}