Skip to main content

quantwave_core/indicators/
frac_diff.rs

1//! Fractional differentiation (Prado) — stationary features with memory preservation.
2//!
3//! **Source**: Marcos López de Prado, *Advances in Financial Machine Learning* (2018), Ch. 5.
4//! Weights \(w_k\) for order \(d\): \(w_0=1\), \(w_k = -w_{k-1}\frac{d-k+1}{k}\), truncated when
5//! \(|w_k| < \text{threshold}\).
6
7use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
8use crate::traits::Next;
9use crate::utils::RingBuffer as VecDeque;
10
11/// Fixed-window fractional differencing on a scalar series.
12#[derive(Debug, Clone)]
13pub struct FracDiff {
14    d: f64,
15    weights: Vec<f64>,
16    window: VecDeque<f64>,
17}
18
19/// Compute truncated binomial weights for fractional order `d`.
20pub fn frac_diff_weights(d: f64, threshold: f64) -> Vec<f64> {
21    let mut w = vec![1.0];
22    let mut k = 1usize;
23    loop {
24        let prev = w[k - 1];
25        let w_k = -prev * (d - k as f64 + 1.0) / k as f64;
26        if w_k.abs() < threshold {
27            break;
28        }
29        w.push(w_k);
30        k += 1;
31        if k > 10_000 {
32            break;
33        }
34    }
35    w
36}
37
38impl FracDiff {
39    pub fn new(d: f64, threshold: f64) -> Self {
40        let d = d.clamp(0.0, 1.0);
41        let threshold = threshold.max(1e-12);
42        let weights = frac_diff_weights(d, threshold);
43        let cap = weights.len().max(1);
44        Self {
45            d,
46            weights,
47            window: VecDeque::with_capacity(cap),
48        }
49    }
50
51    pub fn window_len(&self) -> usize {
52        self.weights.len()
53    }
54
55    pub fn d(&self) -> f64 {
56        self.d
57    }
58}
59
60impl Next<f64> for FracDiff {
61    type Output = f64;
62
63    fn next(&mut self, input: f64) -> Self::Output {
64        if input.is_nan() {
65            return f64::NAN;
66        }
67
68        self.window.push_back(input);
69        let w_len = self.weights.len();
70        while self.window.len() > w_len {
71            self.window.pop_front();
72        }
73
74        if self.window.len() < w_len {
75            return f64::NAN;
76        }
77
78        let mut sum = 0.0;
79        for (k, &wk) in self.weights.iter().enumerate() {
80            let x = self.window[w_len - 1 - k];
81            sum += wk * x;
82        }
83        sum
84    }
85}
86
87pub const FRAC_DIFF_METADATA: IndicatorMetadata = IndicatorMetadata {
88    name: "Fractional Differentiation",
89    description: "Applies Prado-style fractional differencing to preserve memory while reducing non-stationarity in price series.",
90    usage: "Use as an ML feature primitive on log-prices or returns. Lower d (e.g. 0.3–0.5) retains more memory than integer differencing while improving stationarity for tree models and neural nets.",
91    keywords: &[
92        "ml",
93        "stationarity",
94        "prado",
95        "feature-engineering",
96        "fractional",
97    ],
98    ehlers_summary: "",
99    params: &[
100        ParamDef {
101            name: "d",
102            default: "0.4",
103            description: "Fractional differentiation order (0 = identity, 1 = full integer diff)",
104        },
105        ParamDef {
106            name: "threshold",
107            default: "1e-5",
108            description: "Truncate weights when |w_k| falls below this value",
109        },
110    ],
111    formula_source: "https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086",
112    formula_latex: r#"
113\[
114w_0 = 1,\quad w_k = -w_{k-1}\frac{d - k + 1}{k},\quad
115\tilde{X}_t = \sum_{k=0}^{K} w_k X_{t-k}
116\]
117"#,
118    gold_standard_file: "frac_diff.json",
119    category: "ML Features",
120};
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::test_utils::{assert_indicator_parity, load_gold_standard};
126    use crate::traits::Next;
127    use proptest::prelude::*;
128
129    #[test]
130    fn test_frac_diff_weights_basic() {
131        let w = frac_diff_weights(0.4, 1e-5);
132        assert!(w.len() > 2);
133        assert!((w[0] - 1.0).abs() < 1e-12);
134        assert!(w[1] < 0.0);
135    }
136
137    #[test]
138    fn test_frac_diff_warmup_nan() {
139        let mut fd = FracDiff::new(0.4, 1e-4);
140        let w = fd.window_len();
141        for i in 0..(w - 1) {
142            let out = fd.next(i as f64);
143            assert!(out.is_nan(), "expected NaN at bar {i}");
144        }
145        let first = fd.next(100.0);
146        assert!(!first.is_nan());
147    }
148
149    #[test]
150    fn test_frac_diff_gold_standard() {
151        // Gold vector uses d=0.4, threshold=0.05 (compact window for testability).
152        let case = load_gold_standard("frac_diff");
153        let fd = FracDiff::new(0.4, 0.05);
154        assert_indicator_parity(fd, &case.input, &case.expected);
155    }
156
157    proptest! {
158        #[test]
159        fn prop_batch_streaming_parity(
160            data in prop::collection::vec(50.0f64..150.0, 20..80),
161            d in 0.1f64..0.9,
162        ) {
163            let mut stream = FracDiff::new(d, 1e-4);
164            let streaming: Vec<f64> = data.iter().map(|&x| stream.next(x)).collect();
165
166            let mut batch = FracDiff::new(d, 1e-4);
167            let batch_out: Vec<f64> = data.iter().map(|&x| batch.next(x)).collect();
168
169            for (a, b) in streaming.iter().zip(batch_out.iter()) {
170                if a.is_nan() && b.is_nan() {
171                    continue;
172                }
173                prop_assert!((a - b).abs() < 1e-12);
174            }
175        }
176    }
177}