quantwave_core/indicators/
frac_diff.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
8use crate::traits::Next;
9use crate::utils::RingBuffer as VecDeque;
10
11#[derive(Debug, Clone)]
13pub struct FracDiff {
14 d: f64,
15 weights: Vec<f64>,
16 window: VecDeque<f64>,
17}
18
19pub 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 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}