Skip to main content

quantwave_polars/
features.rs

1//! ML Feature Engineering Polars layer (ta.features.*)
2//!
3//! Wires the rich Rust feature extractors from `quantwave_core::features` into
4//! the .ta. namespace on LazyFrame, following the exact patterns from
5//! quantwave-polars/src/lib.rs (UDF map closures + StructChunked::from_series
6//! for rich multi-outputs + with_columns for lazy exprs).
7//!
8//! This delivers the **minimal locked surface** required for the cross-epic
9//! deliverable (ML Features → Realistic Backtest with Rich Metadata) that
10//! closes quantwave-4ps + quantwave-gwx.
11//!
12//! The canonical executable demonstration + parity verification is the notebook:
13//! docs/examples/notebooks/ml_feature_backtest_parity.py
14//! (uses this surface in documented Rust batch path + equivalent Python streaming generators
15//!  + FeatureToSignal adapter + full rich metadata preservation in trades).
16//!
17//! LOCKED SURFACE (per quantwave-4ps notes, "DETAILED WLX SURFACE REQUIRED..." section, 2026-05-31 IST):
18//! 1. .ta.features.hurst(period) -> column "hurst_{period}" (f64 persistence)
19//! 2. .ta.features.cyber_cycle(length) -> Struct column "cyber_cycle" with fields [cycle, trigger, momentum, signal]
20//! 3. .ta.features.griffiths_dominant_cycle(lower, upper, length) -> column "griffiths_dc" (f64)
21//! 4. .ta.features.regime_features() -> column "regime_label" (u32, from HMM bull_bear for MVP usability)
22//! 5. .ta.features.instantaneous_trendline() -> Struct "itl" {trend, strength}
23//! 6. .ta.features.regime_probs() -> Struct "regime_probs" {prob_bull, prob_bear, prob_steady, prob_crisis, prob_other}
24//! 7. .ta.features.trendflex(length) -> column "trendflex_{length}"
25//! 8. .ta.features.ehlers_autocorrelation(length, num_lags) -> Struct {dominant_lag, max_correlation}
26//!
27//! All are lazy (exprs built with with_columns + map UDFs; execution deferred to collect).
28//! All delegate directly to the Next<T> wrappers in quantwave-core (zero lookahead by construction).
29//! No build_matrix yet (per instructions; kept minimal).
30//!
31//! Sources recorded (per AGENTS.md + 4ps spec):
32//! - quantwave-core/src/features/hurst.rs (HurstFeatureExtractor + HurstFeatures; wraps indicators/hurst.rs)
33//! - quantwave-core/src/features/cyber_cycle.rs (CyberCycleFeatureExtractor + CyberCycleFeatures; primary source indicators/cyber_cycle.rs:35 per Ehlers "Cybernetic Analysis...")
34//! - quantwave-core/src/features/griffiths_dominant_cycle.rs (GriffithsDominantCycleFeatureExtractor + ...Features; wraps indicators/griffiths_dominant_cycle.rs)
35//! - quantwave-core/src/features/regime.rs + regimes/hmm.rs (regime_to_features + HMM::bull_bear for label; MarketRegime)
36//! - quantwave-core/src/features/mod.rs (wlx prep note 2026-05-30 + AsFeatures skeleton + proptest parity contract)
37//! - quantwave-4ps epic (parent) + wlx child design notes (this surface is the exact contract for the "smoking gun" notebook)
38//! - Existing .ta. patterns in quantwave-polars/src/lib.rs (macd/bbands/supertrend/gap_momentum struct returns, adosc etc. stateful maps, regimes_conditioned_metrics)
39//! - gw7s notebook (docs/examples/notebooks/ml_feature_stability.py) + quantwave-4ub research (P0 feature list)
40//! - quantwave-backtest (future consumer of the metadata columns from these exprs)
41//!
42//! Decision: CyberCycle uses Struct (matches all rich outputs in this crate on Polars 0.46; users .unnest("cyber_cycle") if needed). Regime uses simple but real HMM label (usable in MVP notebook/backtester filters) rather than pure placeholder.
43
44use polars::prelude::*;
45use quantwave_core::features::{self as rust_features};
46use quantwave_core::traits::Next;
47
48// Bring parent crate type into scope for the inherent impl that extends the .ta. namespace.
49use crate::QuantWaveNamespace;
50
51/// Sub-namespace returned by .ta().features().
52/// Methods here implement the exact locked surface for the 4ps/gwx cross-epic deliverable.
53pub struct TaFeaturesNamespace<'a>(pub(crate) &'a LazyFrame);
54
55impl<'a> QuantWaveNamespace<'a> {
56    /// Entry point for the ML features namespace.
57    /// Usage: df.lazy().ta().features().hurst(20) etc.
58    pub fn features(self) -> TaFeaturesNamespace<'a> {
59        TaFeaturesNamespace(self.0)
60    }
61}
62
63impl<'a> TaFeaturesNamespace<'a> {
64    /// Hurst persistence feature (plus internal regime label in the core extractor).
65    /// Output column: "hurst_{period}" (f64).
66    ///
67    /// Delegates to quantwave_core::features::HurstFeatureExtractor (Next<f64, Output=HurstFeatures>).
68    pub fn hurst(self, period: usize) -> LazyFrame {
69        self.0.clone().with_columns([col("close")
70            .map(
71                move |s| {
72                    let mut extractor = rust_features::HurstFeatureExtractor::new(period);
73                    let ca: &Float64Chunked = s.f64()?;
74                    let mut values = Vec::with_capacity(s.len());
75                    for i in 0..s.len() {
76                        let val = ca.get(i).unwrap_or(f64::NAN);
77                        values.push(extractor.next(val).persistence);
78                    }
79                    Ok(Some(Column::from(Series::new(
80                        format!("hurst_{}", period).into(),
81                        values,
82                    ))))
83                },
84                GetOutput::from_type(DataType::Float64),
85            )
86            .alias(format!("hurst_{}", period))])
87    }
88
89    /// Cyber Cycle rich features (cycle + trigger + derived momentum + signal).
90    /// Returns Struct column named "cyber_cycle" with fields:
91    ///   cycle, trigger, momentum, signal (all f64).
92    ///
93    /// Delegates to quantwave_core::features::CyberCycleFeatureExtractor.
94    /// Struct return matches project convention for multi-output (see macd, bbands, supertrend etc in lib.rs).
95    pub fn cyber_cycle(self, length: usize) -> LazyFrame {
96        self.0.clone().with_columns([col("close")
97            .map(
98                move |s| {
99                    let mut extractor = rust_features::CyberCycleFeatureExtractor::new(length);
100                    let ca: &Float64Chunked = s.f64()?;
101                    let mut cycles = Vec::with_capacity(s.len());
102                    let mut triggers = Vec::with_capacity(s.len());
103                    let mut momenta = Vec::with_capacity(s.len());
104                    let mut signals = Vec::with_capacity(s.len());
105
106                    for i in 0..s.len() {
107                        let val = ca.get(i).unwrap_or(f64::NAN);
108                        let f = extractor.next(val);
109                        cycles.push(f.cycle);
110                        triggers.push(f.trigger);
111                        momenta.push(f.cycle_momentum);
112                        signals.push(f.trigger_signal);
113                    }
114
115                    let s_cycle = Series::new("cycle".into(), cycles);
116                    let s_trigger = Series::new("trigger".into(), triggers);
117                    let s_mom = Series::new("momentum".into(), momenta);
118                    let s_sig = Series::new("signal".into(), signals);
119
120                    let struct_series = StructChunked::from_series(
121                        "cyber_cycle_result".into(),
122                        s.len(),
123                        [s_cycle, s_trigger, s_mom, s_sig].iter(),
124                    )?;
125                    Ok(Some(Column::from(struct_series.into_series())))
126                },
127                GetOutput::from_type(DataType::Struct(vec![
128                    Field::new("cycle".into(), DataType::Float64),
129                    Field::new("trigger".into(), DataType::Float64),
130                    Field::new("momentum".into(), DataType::Float64),
131                    Field::new("signal".into(), DataType::Float64),
132                ])),
133            )
134            .alias("cyber_cycle")])
135    }
136
137    /// Griffiths Dominant Cycle estimate (high-value stationary cycle feature).
138    /// Output column: "griffiths_dc" (f64) — name fixed per locked 4ps deliverable spec (params not encoded in col name).
139    ///
140    /// Delegates to quantwave_core::features::GriffithsDominantCycleFeatureExtractor.
141    pub fn griffiths_dominant_cycle(self, lower: usize, upper: usize, length: usize) -> LazyFrame {
142        self.0.clone().with_columns([col("close")
143            .map(
144                move |s| {
145                    let mut extractor = rust_features::GriffithsDominantCycleFeatureExtractor::new(
146                        lower, upper, length,
147                    );
148                    let ca: &Float64Chunked = s.f64()?;
149                    let mut values = Vec::with_capacity(s.len());
150                    for i in 0..s.len() {
151                        let val = ca.get(i).unwrap_or(f64::NAN);
152                        values.push(extractor.next(val).dominant_cycle);
153                    }
154                    Ok(Some(Column::from(Series::new(
155                        "griffiths_dc".into(),
156                        values,
157                    ))))
158                },
159                GetOutput::from_type(DataType::Float64),
160            )
161            .alias("griffiths_dc")])
162    }
163
164    /// Basic regime label feature (usable for filters/sizing in backtester + MVP notebook).
165    /// Output column: "regime_label" (u32).
166    ///
167    /// For this minimal surface we compute a real label using the HMM bull_bear detector
168    /// on close (consistent with existing regime exprs in lib.rs). Simple label satisfies
169    /// the locked 4ps deliverable spec; richer probs/one-hot can layer on later.
170    ///
171    /// Delegates to quantwave_core::regimes::hmm::HMM + MarketRegime (see also regime.rs helpers).
172    pub fn regime_features(self) -> LazyFrame {
173        self.0.clone().with_columns([col("close")
174            .map(
175                move |s| {
176                    let mut hmm = quantwave_core::regimes::hmm::HMM::bull_bear();
177                    let ca = s.f64()?;
178                    let mut labels = Vec::with_capacity(s.len());
179                    for i in 0..s.len() {
180                        let val = ca.get(i).unwrap_or(f64::NAN);
181                        let regime = if val.is_nan() {
182                            quantwave_core::regimes::MarketRegime::Steady
183                        } else {
184                            hmm.next(val)
185                        };
186                        let label: u32 = match regime {
187                            quantwave_core::regimes::MarketRegime::Bull => 1,
188                            quantwave_core::regimes::MarketRegime::Bear => 2,
189                            quantwave_core::regimes::MarketRegime::Crisis => 3,
190                            quantwave_core::regimes::MarketRegime::Steady => 0,
191                            quantwave_core::regimes::MarketRegime::Cluster(c) => 4 + (c as u32),
192                        };
193                        labels.push(label);
194                    }
195                    Ok(Some(Column::from(Series::new(
196                        "regime_label".into(),
197                        labels,
198                    ))))
199                },
200                GetOutput::from_type(DataType::UInt32),
201            )
202            .alias("regime_label")])
203    }
204
205    /// Instantaneous Trendline (Ehlers) with derived trend-strength feature.
206    /// Returns Struct column "itl" with fields: trend, strength (f64).
207    pub fn instantaneous_trendline(self) -> LazyFrame {
208        self.0.clone().with_columns([col("close")
209            .map(
210                move |s| {
211                    let mut extractor =
212                        rust_features::InstantaneousTrendlineFeatureExtractor::new();
213                    let ca: &Float64Chunked = s.f64()?;
214                    let mut trends = Vec::with_capacity(s.len());
215                    let mut strengths = Vec::with_capacity(s.len());
216                    for i in 0..s.len() {
217                        let val = ca.get(i).unwrap_or(f64::NAN);
218                        let f = extractor.next(val);
219                        trends.push(f.trend);
220                        strengths.push(f.strength);
221                    }
222                    let struct_series = StructChunked::from_series(
223                        "itl_result".into(),
224                        s.len(),
225                        [
226                            Series::new("trend".into(), trends),
227                            Series::new("strength".into(), strengths),
228                        ]
229                        .iter(),
230                    )?;
231                    Ok(Some(Column::from(struct_series.into_series())))
232                },
233                GetOutput::from_type(DataType::Struct(vec![
234                    Field::new("trend".into(), DataType::Float64),
235                    Field::new("strength".into(), DataType::Float64),
236                ])),
237            )
238            .alias("itl")])
239    }
240
241    /// HMM soft regime probabilities (bull/bear forward probs from Viterbi deltas).
242    /// Returns Struct column "regime_probs" with prob_bull, prob_bear, prob_steady, prob_crisis, prob_other.
243    pub fn regime_probs(self) -> LazyFrame {
244        self.0.clone().with_columns([col("close")
245            .map(
246                move |s| {
247                    let mut extractor = rust_features::RegimeProbFeatureExtractor::bull_bear();
248                    let ca = s.f64()?;
249                    let mut bull = Vec::with_capacity(s.len());
250                    let mut bear = Vec::with_capacity(s.len());
251                    let mut steady = Vec::with_capacity(s.len());
252                    let mut crisis = Vec::with_capacity(s.len());
253                    let mut other = Vec::with_capacity(s.len());
254                    for i in 0..s.len() {
255                        let val = ca.get(i).unwrap_or(f64::NAN);
256                        let f = extractor.next(val);
257                        bull.push(f.probs[0]);
258                        bear.push(f.probs[1]);
259                        crisis.push(f.probs[2]);
260                        steady.push(f.probs[3]);
261                        other.push(f.probs[4]);
262                    }
263                    let struct_series = StructChunked::from_series(
264                        "regime_probs_result".into(),
265                        s.len(),
266                        [
267                            Series::new("prob_bull".into(), bull),
268                            Series::new("prob_bear".into(), bear),
269                            Series::new("prob_steady".into(), steady),
270                            Series::new("prob_crisis".into(), crisis),
271                            Series::new("prob_other".into(), other),
272                        ]
273                        .iter(),
274                    )?;
275                    Ok(Some(Column::from(struct_series.into_series())))
276                },
277                GetOutput::from_type(DataType::Struct(vec![
278                    Field::new("prob_bull".into(), DataType::Float64),
279                    Field::new("prob_bear".into(), DataType::Float64),
280                    Field::new("prob_steady".into(), DataType::Float64),
281                    Field::new("prob_crisis".into(), DataType::Float64),
282                    Field::new("prob_other".into(), DataType::Float64),
283                ])),
284            )
285            .alias("regime_probs")])
286    }
287
288    /// Trendflex zero-lag trend component.
289    /// Output column: "trendflex_{length}" (f64).
290    pub fn trendflex(self, length: usize) -> LazyFrame {
291        self.0.clone().with_columns([col("close")
292            .map(
293                move |s| {
294                    let mut extractor = rust_features::TrendflexFeatureExtractor::new(length);
295                    let ca: &Float64Chunked = s.f64()?;
296                    let mut values = Vec::with_capacity(s.len());
297                    for i in 0..s.len() {
298                        let val = ca.get(i).unwrap_or(f64::NAN);
299                        values.push(extractor.next(val).trendflex);
300                    }
301                    Ok(Some(Column::from(Series::new(
302                        format!("trendflex_{}", length).into(),
303                        values,
304                    ))))
305                },
306                GetOutput::from_type(DataType::Float64),
307            )
308            .alias(format!("trendflex_{}", length))])
309    }
310
311    /// Ehlers Autocorrelation summary features (dominant lag + max correlation).
312    /// Returns Struct column "ehlers_autocorr" with dominant_lag (u32), max_correlation (f64).
313    pub fn ehlers_autocorrelation(self, length: usize, num_lags: usize) -> LazyFrame {
314        self.0.clone().with_columns([col("close")
315            .map(
316                move |s| {
317                    let mut extractor =
318                        rust_features::EhlersAutocorrelationFeatureExtractor::new(length, num_lags);
319                    let ca: &Float64Chunked = s.f64()?;
320                    let mut lags = Vec::with_capacity(s.len());
321                    let mut max_corrs = Vec::with_capacity(s.len());
322                    for i in 0..s.len() {
323                        let val = ca.get(i).unwrap_or(f64::NAN);
324                        let f = extractor.next(val);
325                        lags.push(f.dominant_lag as u32);
326                        max_corrs.push(f.max_correlation);
327                    }
328                    let struct_series = StructChunked::from_series(
329                        "ehlers_autocorr_result".into(),
330                        s.len(),
331                        [
332                            Series::new("dominant_lag".into(), lags),
333                            Series::new("max_correlation".into(), max_corrs),
334                        ]
335                        .iter(),
336                    )?;
337                    Ok(Some(Column::from(struct_series.into_series())))
338                },
339                GetOutput::from_type(DataType::Struct(vec![
340                    Field::new("dominant_lag".into(), DataType::UInt32),
341                    Field::new("max_correlation".into(), DataType::Float64),
342                ])),
343            )
344            .alias("ehlers_autocorr")])
345    }
346
347    /// Build the recommended ML feature matrix (all locked `.ta.features.*` outputs).
348    ///
349    /// Chains: hurst, cyber_cycle (struct), griffiths_dc, regime_label, itl (struct),
350    /// regime_probs (struct), trendflex, ehlers_autocorr (struct).
351    /// Matches the `recommended` preset in `quantwave.build_feature_matrix()` (rdpk).
352    pub fn recommended_matrix(self) -> LazyFrame {
353        use crate::QuantWaveExt;
354        self.hurst(100)
355            .ta()
356            .features()
357            .cyber_cycle(30)
358            .ta()
359            .features()
360            .griffiths_dominant_cycle(6, 50, 30)
361            .ta()
362            .features()
363            .regime_features()
364            .ta()
365            .features()
366            .instantaneous_trendline()
367            .ta()
368            .features()
369            .regime_probs()
370            .ta()
371            .features()
372            .trendflex(30)
373            .ta()
374            .features()
375            .ehlers_autocorrelation(30, 10)
376    }
377}
378
379// The struct is pub so it is reachable as quantwave_polars::features::TaFeaturesNamespace if needed for turbofish/docs.
380// No additional re-export required here; the .ta().features() chaining works via the impl on QuantWaveNamespace
381// (the mod features; declaration in lib.rs ensures the impl is linked).
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::QuantWaveExt; // brings .ta() extension method into scope for the smoke test
387
388    /// Smoke test for the exact minimal locked .ta.features.* surface (quantwave-4ps wlx slice).
389    /// Exercises all four methods on a tiny close series; verifies column names, dtypes, and basic collect.
390    /// (Full numeric parity + proptests live in quantwave-core/tests/ per project rules.)
391    #[test]
392    fn smoke_ta_features_surface() -> PolarsResult<()> {
393        // Small oscillatory + trending price series (enough to warm extractors with period ~5-14)
394        let prices: Vec<f64> = (0..40)
395            .map(|i| 100.0 + 3.0 * (i as f64 * 0.4).sin() + (i as f64) * 0.1)
396            .collect();
397
398        let df = df!["close" => prices]?;
399        let lf = df.lazy();
400
401        // 1. hurst
402        let out = lf.clone().ta().features().hurst(8).collect()?;
403        assert!(out.column("hurst_8").is_ok());
404        assert_eq!(out.column("hurst_8")?.dtype(), &DataType::Float64);
405
406        // 2. cyber_cycle -> struct
407        let out = out.lazy().ta().features().cyber_cycle(12).collect()?;
408        let cc = out.column("cyber_cycle")?;
409        assert_eq!(
410            cc.dtype().clone(),
411            DataType::Struct(vec![
412                Field::new("cycle".into(), DataType::Float64),
413                Field::new("trigger".into(), DataType::Float64),
414                Field::new("momentum".into(), DataType::Float64),
415                Field::new("signal".into(), DataType::Float64),
416            ])
417        );
418        let ca = cc.struct_()?;
419        assert!(ca.field_by_name("cycle")?.f64()?.get(39).is_some());
420
421        // 3. griffiths_dominant_cycle -> "griffiths_dc"
422        let out = out
423            .lazy()
424            .ta()
425            .features()
426            .griffiths_dominant_cycle(6, 40, 25)
427            .collect()?;
428        assert!(out.column("griffiths_dc").is_ok());
429        assert_eq!(out.column("griffiths_dc")?.dtype(), &DataType::Float64);
430
431        // 4. regime_features -> "regime_label"
432        let out = out.lazy().ta().features().regime_features().collect()?;
433        assert!(out.column("regime_label").is_ok());
434        assert_eq!(out.column("regime_label")?.dtype(), &DataType::UInt32);
435
436        let out = out
437            .lazy()
438            .ta()
439            .features()
440            .instantaneous_trendline()
441            .collect()?;
442        assert!(out.column("itl").is_ok());
443
444        let out = out.lazy().ta().features().regime_probs().collect()?;
445        assert!(out.column("regime_probs").is_ok());
446
447        let out = out.lazy().ta().features().trendflex(20).collect()?;
448        assert!(out.column("trendflex_20").is_ok());
449
450        let out = out
451            .lazy()
452            .ta()
453            .features()
454            .ehlers_autocorrelation(30, 10)
455            .collect()?;
456        assert!(out.column("ehlers_autocorr").is_ok());
457
458        Ok(())
459    }
460}