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