Skip to main content

fin_primitives/ml/
mod.rs

1//! ML feature vector builder: snapshot N indicator outputs, normalize, and serialize for ML pipelines.
2//!
3//! ## Responsibility
4//! ML feature vector construction from indicator snapshots.
5//! Provides a [`FeatureVector`] builder that captures N indicator outputs at a point in time,
6//! normalizes them (z-score or min-max), and serializes to `Vec<f64>` for ML pipelines.
7//!
8//! ## Guarantees
9//! - Zero panics: all fallible operations return `Result<_, FinError>`
10//! - Feature names are validated at construction; duplicates are rejected
11//! - Normalization is stable in the presence of zero-variance features (returns 0.0)
12//! - `Into<Vec<f64>>` produces values in the same order as the feature names
13//!
14//! ## NOT Responsible For
15//! - Model training or inference
16//! - Persistence beyond `Vec<f64>` serialization
17
18use crate::error::FinError;
19
20/// Normalization strategy applied to raw feature values.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Normalization {
23    /// No normalization; raw values are used as-is.
24    None,
25    /// Z-score: `(x - mean) / std_dev`. Zero-variance features become `0.0`.
26    ZScore,
27    /// Min-max: `(x - min) / (max - min)`. Constant features become `0.0`.
28    MinMax,
29}
30
31/// A named feature snapshot at a single point in time.
32///
33/// Constructed via [`FeatureVectorBuilder`] then finalized into [`FeatureVector`].
34#[derive(Debug, Clone)]
35pub struct FeatureVector {
36    /// Ordered feature names.
37    names: Vec<String>,
38    /// Raw (un-normalized) feature values, parallel to `names`.
39    raw: Vec<f64>,
40    /// Normalization mode applied when converting to `Vec<f64>`.
41    normalization: Normalization,
42}
43
44impl FeatureVector {
45    /// Returns the number of features.
46    pub fn len(&self) -> usize {
47        self.names.len()
48    }
49
50    /// Returns `true` if no features have been added.
51    pub fn is_empty(&self) -> bool {
52        self.names.is_empty()
53    }
54
55    /// Returns a slice of feature names in insertion order.
56    pub fn names(&self) -> &[String] {
57        &self.names
58    }
59
60    /// Returns raw (un-normalized) values in insertion order.
61    pub fn raw_values(&self) -> &[f64] {
62        &self.raw
63    }
64
65    /// Returns the normalization mode.
66    pub fn normalization(&self) -> Normalization {
67        self.normalization
68    }
69
70    /// Returns the raw value for a feature by name, or `None` if not found.
71    pub fn get_by_name(&self, name: &str) -> Option<f64> {
72        self.names.iter().position(|n| n == name).map(|i| self.raw[i])
73    }
74
75    /// Normalizes raw values according to the configured [`Normalization`] mode
76    /// and returns them as a `Vec<f64>`.
77    ///
78    /// - [`Normalization::None`]: returns raw values unchanged.
79    /// - [`Normalization::ZScore`]: subtracts mean and divides by std dev.
80    ///   Zero-variance features → `0.0`.
81    /// - [`Normalization::MinMax`]: scales to `[0, 1]`.
82    ///   Constant features → `0.0`.
83    pub fn to_normalized_vec(&self) -> Vec<f64> {
84        match self.normalization {
85            Normalization::None => self.raw.clone(),
86            Normalization::ZScore => zscore_normalize(&self.raw),
87            Normalization::MinMax => minmax_normalize(&self.raw),
88        }
89    }
90}
91
92/// Implements `Into<Vec<f64>>` by applying normalization.
93impl From<FeatureVector> for Vec<f64> {
94    fn from(fv: FeatureVector) -> Vec<f64> {
95        fv.to_normalized_vec()
96    }
97}
98
99/// Implements construction from a fixed-size array reference.
100///
101/// Creates a `FeatureVector` with auto-generated names `"f0"`, `"f1"`, … and
102/// [`Normalization::None`].
103impl<const N: usize> From<&[f64; N]> for FeatureVector {
104    fn from(arr: &[f64; N]) -> Self {
105        let names: Vec<String> = (0..N).map(|i| format!("f{i}")).collect();
106        let raw: Vec<f64> = arr.iter().copied().collect();
107        Self { names, raw, normalization: Normalization::None }
108    }
109}
110
111/// Builder for [`FeatureVector`].
112///
113/// # Example
114/// ```rust
115/// use fin_primitives::ml::{FeatureVectorBuilder, Normalization};
116///
117/// let fv = FeatureVectorBuilder::new(Normalization::ZScore)
118///     .add("rsi", 55.3).unwrap()
119///     .add("adx", 28.1).unwrap()
120///     .add("hurst", 0.6).unwrap()
121///     .build();
122///
123/// assert_eq!(fv.len(), 3);
124/// let normalized: Vec<f64> = fv.into();
125/// assert_eq!(normalized.len(), 3);
126/// ```
127#[derive(Debug, Default)]
128pub struct FeatureVectorBuilder {
129    names: Vec<String>,
130    raw: Vec<f64>,
131    normalization: Normalization,
132}
133
134impl Default for Normalization {
135    fn default() -> Self {
136        Normalization::None
137    }
138}
139
140impl FeatureVectorBuilder {
141    /// Creates a new builder with the specified normalization mode.
142    pub fn new(normalization: Normalization) -> Self {
143        Self { names: Vec::new(), raw: Vec::new(), normalization }
144    }
145
146    /// Adds a named feature with a raw `f64` value.
147    ///
148    /// # Errors
149    /// Returns [`FinError::InvalidInput`] if:
150    /// - `name` is empty or contains only whitespace.
151    /// - A feature with the same name already exists.
152    pub fn add(mut self, name: impl Into<String>, value: f64) -> Result<Self, FinError> {
153        let name = name.into();
154        if name.trim().is_empty() {
155            return Err(FinError::InvalidInput(
156                "feature name must not be empty or whitespace".to_owned(),
157            ));
158        }
159        if self.names.iter().any(|n| n == &name) {
160            return Err(FinError::InvalidInput(format!(
161                "duplicate feature name: '{name}'"
162            )));
163        }
164        self.names.push(name);
165        self.raw.push(value);
166        Ok(self)
167    }
168
169    /// Adds multiple features from a slice of `(name, value)` pairs.
170    ///
171    /// # Errors
172    /// Propagates errors from [`Self::add`].
173    pub fn add_all(
174        mut self,
175        features: &[(&str, f64)],
176    ) -> Result<Self, FinError> {
177        for (name, value) in features {
178            self = self.add(*name, *value)?;
179        }
180        Ok(self)
181    }
182
183    /// Finalizes the builder and returns a [`FeatureVector`].
184    pub fn build(self) -> FeatureVector {
185        FeatureVector {
186            names: self.names,
187            raw: self.raw,
188            normalization: self.normalization,
189        }
190    }
191}
192
193// ---------- normalization helpers ----------
194
195fn zscore_normalize(values: &[f64]) -> Vec<f64> {
196    let n = values.len() as f64;
197    if n == 0.0 {
198        return Vec::new();
199    }
200    let mean = values.iter().sum::<f64>() / n;
201    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
202    let std_dev = variance.sqrt();
203    if std_dev == 0.0 {
204        return vec![0.0; values.len()];
205    }
206    values.iter().map(|v| (v - mean) / std_dev).collect()
207}
208
209fn minmax_normalize(values: &[f64]) -> Vec<f64> {
210    if values.is_empty() {
211        return Vec::new();
212    }
213    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
214    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
215    let range = max - min;
216    if range == 0.0 {
217        return vec![0.0; values.len()];
218    }
219    values.iter().map(|v| (v - min) / range).collect()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_builder_basic() {
228        let fv = FeatureVectorBuilder::new(Normalization::None)
229            .add("rsi", 60.0)
230            .unwrap()
231            .add("adx", 30.0)
232            .unwrap()
233            .build();
234        assert_eq!(fv.len(), 2);
235        assert_eq!(fv.names(), &["rsi", "adx"]);
236        assert_eq!(fv.raw_values(), &[60.0, 30.0]);
237    }
238
239    #[test]
240    fn test_duplicate_name_rejected() {
241        let result = FeatureVectorBuilder::new(Normalization::None)
242            .add("rsi", 60.0)
243            .unwrap()
244            .add("rsi", 70.0);
245        assert!(matches!(result, Err(FinError::InvalidInput(_))));
246    }
247
248    #[test]
249    fn test_empty_name_rejected() {
250        let result = FeatureVectorBuilder::new(Normalization::None).add("  ", 1.0);
251        assert!(matches!(result, Err(FinError::InvalidInput(_))));
252    }
253
254    #[test]
255    fn test_zscore_normalization() {
256        let fv = FeatureVectorBuilder::new(Normalization::ZScore)
257            .add("a", 10.0)
258            .unwrap()
259            .add("b", 20.0)
260            .unwrap()
261            .add("c", 30.0)
262            .unwrap()
263            .build();
264        let norm: Vec<f64> = fv.into();
265        // mean=20, std=8.165, so z(-10/8.165, 0, +10/8.165)
266        assert!((norm[1]).abs() < 1e-10, "middle value should be 0 after z-score");
267        assert!(norm[0] < 0.0);
268        assert!(norm[2] > 0.0);
269    }
270
271    #[test]
272    fn test_minmax_normalization() {
273        let fv = FeatureVectorBuilder::new(Normalization::MinMax)
274            .add("a", 0.0)
275            .unwrap()
276            .add("b", 50.0)
277            .unwrap()
278            .add("c", 100.0)
279            .unwrap()
280            .build();
281        let norm: Vec<f64> = fv.into();
282        assert!((norm[0] - 0.0).abs() < 1e-10);
283        assert!((norm[1] - 0.5).abs() < 1e-10);
284        assert!((norm[2] - 1.0).abs() < 1e-10);
285    }
286
287    #[test]
288    fn test_zero_variance_zscore() {
289        let fv = FeatureVectorBuilder::new(Normalization::ZScore)
290            .add("a", 5.0)
291            .unwrap()
292            .add("b", 5.0)
293            .unwrap()
294            .build();
295        let norm: Vec<f64> = fv.into();
296        assert_eq!(norm, vec![0.0, 0.0]);
297    }
298
299    #[test]
300    fn test_zero_variance_minmax() {
301        let fv = FeatureVectorBuilder::new(Normalization::MinMax)
302            .add("a", 7.0)
303            .unwrap()
304            .add("b", 7.0)
305            .unwrap()
306            .build();
307        let norm: Vec<f64> = fv.into();
308        assert_eq!(norm, vec![0.0, 0.0]);
309    }
310
311    #[test]
312    fn test_from_fixed_array() {
313        let arr = [1.0_f64, 2.0, 3.0];
314        let fv = FeatureVector::from(&arr);
315        assert_eq!(fv.len(), 3);
316        assert_eq!(fv.names(), &["f0", "f1", "f2"]);
317        assert_eq!(fv.raw_values(), &[1.0, 2.0, 3.0]);
318    }
319
320    #[test]
321    fn test_get_by_name() {
322        let fv = FeatureVectorBuilder::new(Normalization::None)
323            .add("vol", 0.25)
324            .unwrap()
325            .build();
326        assert_eq!(fv.get_by_name("vol"), Some(0.25));
327        assert_eq!(fv.get_by_name("missing"), None);
328    }
329
330    #[test]
331    fn test_add_all() {
332        let features = [("x", 1.0), ("y", 2.0), ("z", 3.0)];
333        let fv = FeatureVectorBuilder::new(Normalization::None)
334            .add_all(&features)
335            .unwrap()
336            .build();
337        assert_eq!(fv.len(), 3);
338    }
339
340    #[test]
341    fn test_into_vec_none_normalization() {
342        let fv = FeatureVectorBuilder::new(Normalization::None)
343            .add("a", 42.0)
344            .unwrap()
345            .build();
346        let v: Vec<f64> = fv.into();
347        assert_eq!(v, vec![42.0]);
348    }
349}