Skip to main content

fin_primitives/ml_features/
mod.rs

1//! ML feature engineering: price features, microstructure features, feature vectors,
2//! z-score normalization, cross-sectional ranking, and lagged feature construction.
3//!
4//! ## Responsibility
5//! ML feature engineering for financial time-series: price-based features,
6//! microstructure features, feature vector abstraction, normalization,
7//! cross-sectional ranking, and lagged feature construction.
8//!
9//! ## Guarantees
10//! - Zero panics; all fallible operations return `Result<_, FinError>`
11//! - Normalizer must be `fit` before `transform` — enforced at runtime
12//! - `LaggedFeatures` pads short series with `f64::NAN` (flagged, not silent)
13
14use crate::error::FinError;
15
16// ─────────────────────────────────────────
17//  PriceFeatures
18// ─────────────────────────────────────────
19
20/// Price-derived features computed from a close-price series.
21///
22/// # Example
23/// ```rust
24/// use fin_primitives::ml_features::PriceFeatures;
25///
26/// let closes = vec![100.0, 101.0, 102.0, 101.5, 103.0];
27/// let pf = PriceFeatures::compute(&closes, 3).unwrap();
28/// assert!(pf.log_returns.len() == 4);
29/// ```
30#[derive(Debug, Clone)]
31pub struct PriceFeatures {
32    /// Log returns: `ln(close_t / close_{t-1})`.
33    pub log_returns: Vec<f64>,
34    /// Realized volatility: rolling std-dev of log returns over the last `window` periods.
35    pub realized_volatility: f64,
36    /// n-period momentum: `close[-1] / close[-n-1] - 1`.
37    pub momentum: f64,
38    /// RSI over the last `window` periods (0–100).
39    pub rsi: f64,
40    /// MACD signal line: EMA(12) − EMA(26) of closes.
41    pub macd_signal: f64,
42    /// Bollinger Band position: `(close - lower) / (upper - lower)`, in `[0, 1]`.
43    pub bollinger_position: f64,
44}
45
46impl PriceFeatures {
47    /// Compute price features from a close-price series.
48    ///
49    /// Requires at least `window + 1` observations for all features.
50    ///
51    /// # Errors
52    /// - `FinError::InsufficientData` if fewer than `window + 2` closes.
53    /// - [`FinError::InvalidPeriod`] if `window == 0`.
54    pub fn compute(closes: &[f64], window: usize) -> Result<Self, FinError> {
55        if window == 0 {
56            return Err(FinError::InvalidPeriod(0));
57        }
58        if closes.len() < window + 2 {
59            return Err(FinError::InvalidInput("insufficient close prices for window".into()));
60        }
61        let n = closes.len();
62        // Log returns
63        let log_returns: Vec<f64> = (1..n)
64            .map(|i| (closes[i] / closes[i - 1]).ln())
65            .collect();
66
67        // Realized volatility (std of log returns over last `window` returns)
68        let rv_slice = &log_returns[log_returns.len().saturating_sub(window)..];
69        let realized_volatility = std_dev(rv_slice);
70
71        // Momentum: current vs n periods ago
72        let momentum = if n > window {
73            closes[n - 1] / closes[n - 1 - window] - 1.0
74        } else {
75            0.0
76        };
77
78        // RSI
79        let rsi = compute_rsi(&log_returns, window);
80
81        // MACD signal (EMA12 - EMA26 applied to closes)
82        let macd_signal = compute_macd_signal(closes);
83
84        // Bollinger band position
85        let bollinger_position = compute_bollinger_position(closes, window);
86
87        Ok(Self {
88            log_returns,
89            realized_volatility,
90            momentum,
91            rsi,
92            macd_signal,
93            bollinger_position,
94        })
95    }
96}
97
98fn mean(data: &[f64]) -> f64 {
99    if data.is_empty() {
100        return 0.0;
101    }
102    data.iter().sum::<f64>() / data.len() as f64
103}
104
105fn std_dev(data: &[f64]) -> f64 {
106    if data.len() < 2 {
107        return 0.0;
108    }
109    let m = mean(data);
110    let var = data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (data.len() - 1) as f64;
111    var.sqrt()
112}
113
114fn ema(data: &[f64], period: usize) -> f64 {
115    if data.is_empty() || period == 0 {
116        return 0.0;
117    }
118    let k = 2.0 / (period as f64 + 1.0);
119    let mut ema_val = data[0];
120    for &v in &data[1..] {
121        ema_val = v * k + ema_val * (1.0 - k);
122    }
123    ema_val
124}
125
126fn compute_rsi(log_returns: &[f64], window: usize) -> f64 {
127    let slice = &log_returns[log_returns.len().saturating_sub(window)..];
128    if slice.is_empty() {
129        return 50.0;
130    }
131    let gains: Vec<f64> = slice.iter().map(|r| r.max(0.0)).collect();
132    let losses: Vec<f64> = slice.iter().map(|r| (-r).max(0.0)).collect();
133    let avg_gain = mean(&gains);
134    let avg_loss = mean(&losses);
135    if avg_loss == 0.0 {
136        return 100.0;
137    }
138    let rs = avg_gain / avg_loss;
139    100.0 - 100.0 / (1.0 + rs)
140}
141
142fn compute_macd_signal(closes: &[f64]) -> f64 {
143    if closes.len() < 26 {
144        return 0.0;
145    }
146    let ema12 = ema(closes, 12);
147    let ema26 = ema(closes, 26);
148    ema12 - ema26
149}
150
151fn compute_bollinger_position(closes: &[f64], window: usize) -> f64 {
152    let n = closes.len();
153    let slice = &closes[n.saturating_sub(window)..];
154    if slice.len() < 2 {
155        return 0.5;
156    }
157    let m = mean(slice);
158    let sd = std_dev(slice);
159    if sd == 0.0 {
160        return 0.5;
161    }
162    let upper = m + 2.0 * sd;
163    let lower = m - 2.0 * sd;
164    let range = upper - lower;
165    if range == 0.0 {
166        return 0.5;
167    }
168    ((closes[n - 1] - lower) / range).clamp(0.0, 1.0)
169}
170
171// ─────────────────────────────────────────
172//  MicrostructureFeatures
173// ─────────────────────────────────────────
174
175/// Market microstructure-derived features.
176///
177/// # Example
178/// ```rust
179/// use fin_primitives::ml_features::MicrostructureFeatures;
180///
181/// let f = MicrostructureFeatures::compute(1000.0, 800.0, 50, 0.01, 100_000.0);
182/// assert!(f.order_imbalance.abs() <= 1.0);
183/// ```
184#[derive(Debug, Clone, Copy)]
185pub struct MicrostructureFeatures {
186    /// Order imbalance: `(bid_vol - ask_vol) / (bid_vol + ask_vol)` in `[-1, 1]`.
187    pub order_imbalance: f64,
188    /// Trade intensity: number of trades per unit time (trades/minute equivalent).
189    pub trade_intensity: f64,
190    /// Price impact coefficient: `|Δprice| / dollar_volume` (Kyle's λ proxy).
191    pub price_impact_coefficient: f64,
192}
193
194impl MicrostructureFeatures {
195    /// Compute microstructure features.
196    ///
197    /// - `bid_volume`, `ask_volume`: cumulative bid/ask volumes at top of book.
198    /// - `trade_count`: number of trades in the observation window.
199    /// - `price_move`: absolute price change over the window.
200    /// - `dollar_volume`: total dollar volume traded over the window.
201    #[must_use]
202    pub fn compute(
203        bid_volume: f64,
204        ask_volume: f64,
205        trade_count: u64,
206        price_move: f64,
207        dollar_volume: f64,
208    ) -> Self {
209        let total = bid_volume + ask_volume;
210        let order_imbalance = if total == 0.0 {
211            0.0
212        } else {
213            (bid_volume - ask_volume) / total
214        };
215        let trade_intensity = trade_count as f64;
216        let price_impact_coefficient = if dollar_volume == 0.0 {
217            0.0
218        } else {
219            price_move.abs() / dollar_volume
220        };
221        Self {
222            order_imbalance,
223            trade_intensity,
224            price_impact_coefficient,
225        }
226    }
227}
228
229// ─────────────────────────────────────────
230//  FeatureVector
231// ─────────────────────────────────────────
232
233/// A named feature vector for ML pipelines.
234///
235/// # Example
236/// ```rust
237/// use fin_primitives::ml_features::FeatureVector;
238///
239/// let fv = FeatureVector::new(
240///     vec!["momentum".into(), "rsi".into()],
241///     vec![0.02, 65.0],
242/// ).unwrap();
243/// assert_eq!(fv.get("rsi"), Some(65.0));
244/// ```
245#[derive(Debug, Clone)]
246pub struct FeatureVector {
247    /// Feature names in the same order as `values`.
248    pub names: Vec<String>,
249    /// Feature values.
250    pub values: Vec<f64>,
251}
252
253impl FeatureVector {
254    /// Constructs a [`FeatureVector`].
255    ///
256    /// # Errors
257    /// [`FinError::InvalidInput`] if `names` and `values` have different lengths.
258    pub fn new(names: Vec<String>, values: Vec<f64>) -> Result<Self, FinError> {
259        if names.len() != values.len() {
260            return Err(FinError::InvalidInput(
261                "names and values must have the same length".into(),
262            ));
263        }
264        Ok(Self { names, values })
265    }
266
267    /// Returns the value for the given feature name, or `None`.
268    #[must_use]
269    pub fn get(&self, name: &str) -> Option<f64> {
270        self.names
271            .iter()
272            .position(|n| n == name)
273            .map(|i| self.values[i])
274    }
275
276    /// Append a feature to the vector.
277    pub fn push(&mut self, name: impl Into<String>, value: f64) {
278        self.names.push(name.into());
279        self.values.push(value);
280    }
281
282    /// Number of features.
283    #[must_use]
284    pub fn len(&self) -> usize {
285        self.values.len()
286    }
287
288    /// Returns `true` if no features are present.
289    #[must_use]
290    pub fn is_empty(&self) -> bool {
291        self.values.is_empty()
292    }
293}
294
295// ─────────────────────────────────────────
296//  FeatureNormalizer
297// ─────────────────────────────────────────
298
299/// Z-score (standardization) normalizer: `(x - mean) / std`.
300///
301/// Must be `fit` on training data before calling `transform`.
302///
303/// # Example
304/// ```rust
305/// use fin_primitives::ml_features::FeatureNormalizer;
306///
307/// let mut norm = FeatureNormalizer::new();
308/// norm.fit(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
309/// let z = norm.transform(3.0).unwrap();
310/// assert!((z - 0.0).abs() < 1e-10);
311/// ```
312#[derive(Debug, Clone)]
313pub struct FeatureNormalizer {
314    mean: Option<f64>,
315    std: Option<f64>,
316}
317
318impl FeatureNormalizer {
319    /// Constructs an unfitted normalizer.
320    #[must_use]
321    pub fn new() -> Self {
322        Self {
323            mean: None,
324            std: None,
325        }
326    }
327
328    /// Fit the normalizer to a dataset.
329    ///
330    /// # Errors
331    /// `FinError::InsufficientData` if fewer than 2 observations.
332    /// [`FinError::InvalidInput`] if std dev is zero (constant series).
333    pub fn fit(&mut self, data: &[f64]) -> Result<(), FinError> {
334        if data.len() < 2 {
335            return Err(FinError::InvalidInput("need at least 2 data points to normalize".into()));
336        }
337        let m = mean(data);
338        let s = std_dev(data);
339        if s == 0.0 {
340            return Err(FinError::InvalidInput(
341                "cannot normalize a constant series".into(),
342            ));
343        }
344        self.mean = Some(m);
345        self.std = Some(s);
346        Ok(())
347    }
348
349    /// Transform a single value to z-score.
350    ///
351    /// # Errors
352    /// [`FinError::InvalidInput`] if the normalizer has not been fit.
353    pub fn transform(&self, value: f64) -> Result<f64, FinError> {
354        match (self.mean, self.std) {
355            (Some(m), Some(s)) => Ok((value - m) / s),
356            _ => Err(FinError::InvalidInput(
357                "normalizer must be fit before transform".into(),
358            )),
359        }
360    }
361
362    /// Inverse transform a z-score back to original scale.
363    ///
364    /// # Errors
365    /// [`FinError::InvalidInput`] if the normalizer has not been fit.
366    pub fn inverse_transform(&self, z: f64) -> Result<f64, FinError> {
367        match (self.mean, self.std) {
368            (Some(m), Some(s)) => Ok(z * s + m),
369            _ => Err(FinError::InvalidInput(
370                "normalizer must be fit before inverse_transform".into(),
371            )),
372        }
373    }
374
375    /// Transform a slice of values in place.
376    ///
377    /// # Errors
378    /// [`FinError::InvalidInput`] if the normalizer has not been fit.
379    pub fn transform_slice(&self, values: &[f64]) -> Result<Vec<f64>, FinError> {
380        values.iter().map(|v| self.transform(*v)).collect()
381    }
382}
383
384impl Default for FeatureNormalizer {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390// ─────────────────────────────────────────
391//  CrossSectionalRanker
392// ─────────────────────────────────────────
393
394/// Ranks a cross-section of feature values as `rank / n` ∈ `(0, 1]`.
395///
396/// Ties are broken by stable order (first occurrence gets lower rank).
397///
398/// # Example
399/// ```rust
400/// use fin_primitives::ml_features::CrossSectionalRanker;
401///
402/// let ranked = CrossSectionalRanker::rank(&[30.0, 10.0, 20.0]);
403/// // 10 → rank 1, 20 → rank 2, 30 → rank 3; n = 3
404/// assert!((ranked[0] - 1.0).abs() < 1e-10); // 30 → 3/3
405/// assert!((ranked[1] - 1.0/3.0).abs() < 1e-10); // 10 → 1/3
406/// assert!((ranked[2] - 2.0/3.0).abs() < 1e-10); // 20 → 2/3
407/// ```
408pub struct CrossSectionalRanker;
409
410impl CrossSectionalRanker {
411    /// Rank values cross-sectionally.
412    ///
413    /// Returns a vector of `rank / n` values in the same order as input.
414    /// Empty input returns an empty vector.
415    #[must_use]
416    pub fn rank(values: &[f64]) -> Vec<f64> {
417        let n = values.len();
418        if n == 0 {
419            return vec![];
420        }
421        // Create (value, original_index) sorted by value ascending
422        let mut indexed: Vec<(f64, usize)> = values
423            .iter()
424            .enumerate()
425            .map(|(i, &v)| (v, i))
426            .collect();
427        indexed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
428        let mut ranks = vec![0.0_f64; n];
429        for (rank_idx, (_, orig_idx)) in indexed.iter().enumerate() {
430            ranks[*orig_idx] = (rank_idx + 1) as f64 / n as f64;
431        }
432        ranks
433    }
434}
435
436// ─────────────────────────────────────────
437//  LaggedFeatures
438// ─────────────────────────────────────────
439
440/// Creates lagged versions of a feature series at standard lags (1, 5, 10, 21).
441///
442/// Missing values at the beginning of a lag series are filled with `f64::NAN`.
443///
444/// # Example
445/// ```rust
446/// use fin_primitives::ml_features::LaggedFeatures;
447///
448/// let series = (1..=25).map(|i| i as f64).collect::<Vec<_>>();
449/// let lagged = LaggedFeatures::compute(&series);
450/// // lag-1 of index 1 = series[0] = 1.0
451/// assert!((lagged.lag1[1] - 1.0).abs() < 1e-10);
452/// // lag-5 of index 0 is NaN
453/// assert!(lagged.lag5[0].is_nan());
454/// ```
455#[derive(Debug, Clone)]
456pub struct LaggedFeatures {
457    /// Original series lagged by 1 period.
458    pub lag1: Vec<f64>,
459    /// Original series lagged by 5 periods.
460    pub lag5: Vec<f64>,
461    /// Original series lagged by 10 periods.
462    pub lag10: Vec<f64>,
463    /// Original series lagged by 21 periods.
464    pub lag21: Vec<f64>,
465}
466
467impl LaggedFeatures {
468    /// Compute lagged feature series from a price/return series.
469    ///
470    /// The output vectors have the same length as the input.
471    /// Positions before the lag are `f64::NAN`.
472    #[must_use]
473    pub fn compute(series: &[f64]) -> Self {
474        Self {
475            lag1: Self::lag(series, 1),
476            lag5: Self::lag(series, 5),
477            lag10: Self::lag(series, 10),
478            lag21: Self::lag(series, 21),
479        }
480    }
481
482    fn lag(series: &[f64], n: usize) -> Vec<f64> {
483        let len = series.len();
484        let mut out = vec![f64::NAN; len];
485        for i in n..len {
486            out[i] = series[i - n];
487        }
488        out
489    }
490
491    /// Returns all four lag series as a vec of (lag_name, values) pairs.
492    #[must_use]
493    pub fn as_named_pairs(&self) -> Vec<(&'static str, &[f64])> {
494        vec![
495            ("lag1", &self.lag1),
496            ("lag5", &self.lag5),
497            ("lag10", &self.lag10),
498            ("lag21", &self.lag21),
499        ]
500    }
501}
502
503// ─────────────────────────────────────────
504//  Unit Tests
505// ─────────────────────────────────────────
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    // PriceFeatures
512    #[test]
513    fn price_features_basic() {
514        let closes: Vec<f64> = (1..=30).map(|i| 100.0 + i as f64).collect();
515        let pf = PriceFeatures::compute(&closes, 10).unwrap();
516        assert!(!pf.log_returns.is_empty());
517        assert!(pf.realized_volatility >= 0.0);
518        assert!(pf.rsi >= 0.0 && pf.rsi <= 100.0);
519        assert!(pf.bollinger_position >= 0.0 && pf.bollinger_position <= 1.0);
520    }
521
522    #[test]
523    fn price_features_too_short() {
524        let closes = vec![100.0, 101.0];
525        assert!(PriceFeatures::compute(&closes, 5).is_err());
526    }
527
528    #[test]
529    fn price_features_window_zero() {
530        let closes = vec![100.0; 20];
531        assert!(PriceFeatures::compute(&closes, 0).is_err());
532    }
533
534    // MicrostructureFeatures
535    #[test]
536    fn microstructure_order_imbalance_balanced() {
537        let f = MicrostructureFeatures::compute(500.0, 500.0, 100, 0.01, 1_000_000.0);
538        assert!((f.order_imbalance - 0.0).abs() < 1e-10);
539    }
540
541    #[test]
542    fn microstructure_order_imbalance_extreme() {
543        let f = MicrostructureFeatures::compute(1000.0, 0.0, 50, 0.01, 500_000.0);
544        assert!((f.order_imbalance - 1.0).abs() < 1e-10);
545    }
546
547    #[test]
548    fn microstructure_zero_volume() {
549        let f = MicrostructureFeatures::compute(0.0, 0.0, 0, 0.0, 0.0);
550        assert_eq!(f.order_imbalance, 0.0);
551        assert_eq!(f.price_impact_coefficient, 0.0);
552    }
553
554    // FeatureVector
555    #[test]
556    fn feature_vector_get() {
557        let fv = FeatureVector::new(
558            vec!["rsi".into(), "macd".into()],
559            vec![65.0, 0.5],
560        )
561        .unwrap();
562        assert_eq!(fv.get("rsi"), Some(65.0));
563        assert_eq!(fv.get("missing"), None);
564    }
565
566    #[test]
567    fn feature_vector_length_mismatch() {
568        assert!(FeatureVector::new(vec!["a".into()], vec![1.0, 2.0]).is_err());
569    }
570
571    #[test]
572    fn feature_vector_push() {
573        let mut fv = FeatureVector::new(vec![], vec![]).unwrap();
574        fv.push("vol", 0.02);
575        assert_eq!(fv.len(), 1);
576        assert_eq!(fv.get("vol"), Some(0.02));
577    }
578
579    // FeatureNormalizer
580    #[test]
581    fn normalizer_fit_transform() {
582        let mut norm = FeatureNormalizer::new();
583        norm.fit(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
584        let z = norm.transform(3.0).unwrap();
585        assert!((z - 0.0).abs() < 1e-10);
586    }
587
588    #[test]
589    fn normalizer_inverse_transform() {
590        let mut norm = FeatureNormalizer::new();
591        norm.fit(&[10.0, 20.0, 30.0]).unwrap();
592        let z = norm.transform(20.0).unwrap();
593        let back = norm.inverse_transform(z).unwrap();
594        assert!((back - 20.0).abs() < 1e-10);
595    }
596
597    #[test]
598    fn normalizer_not_fit_errors() {
599        let norm = FeatureNormalizer::new();
600        assert!(norm.transform(1.0).is_err());
601        assert!(norm.inverse_transform(0.0).is_err());
602    }
603
604    #[test]
605    fn normalizer_constant_series_errors() {
606        let mut norm = FeatureNormalizer::new();
607        assert!(norm.fit(&[5.0, 5.0, 5.0]).is_err());
608    }
609
610    // CrossSectionalRanker
611    #[test]
612    fn ranker_basic() {
613        let ranked = CrossSectionalRanker::rank(&[30.0, 10.0, 20.0]);
614        assert!((ranked[0] - 1.0).abs() < 1e-10);
615        assert!((ranked[1] - 1.0 / 3.0).abs() < 1e-10);
616        assert!((ranked[2] - 2.0 / 3.0).abs() < 1e-10);
617    }
618
619    #[test]
620    fn ranker_empty() {
621        assert!(CrossSectionalRanker::rank(&[]).is_empty());
622    }
623
624    #[test]
625    fn ranker_single() {
626        let ranked = CrossSectionalRanker::rank(&[42.0]);
627        assert!((ranked[0] - 1.0).abs() < 1e-10);
628    }
629
630    // LaggedFeatures
631    #[test]
632    fn lagged_features_basic() {
633        let series: Vec<f64> = (1..=25).map(|i| i as f64).collect();
634        let lf = LaggedFeatures::compute(&series);
635        // lag1[0] = NaN, lag1[1] = series[0] = 1.0
636        assert!(lf.lag1[0].is_nan());
637        assert!((lf.lag1[1] - 1.0).abs() < 1e-10);
638        // lag5[4] = NaN, lag5[5] = series[0] = 1.0
639        assert!(lf.lag5[4].is_nan());
640        assert!((lf.lag5[5] - 1.0).abs() < 1e-10);
641    }
642
643    #[test]
644    fn lagged_features_lag21_length() {
645        let series: Vec<f64> = (1..=30).map(|i| i as f64).collect();
646        let lf = LaggedFeatures::compute(&series);
647        assert_eq!(lf.lag21.len(), series.len());
648        assert!(lf.lag21[20].is_nan());
649        assert!((lf.lag21[21] - 1.0).abs() < 1e-10);
650    }
651}