fin_primitives/ml_features/
mod.rs1use crate::error::FinError;
15
16#[derive(Debug, Clone)]
31pub struct PriceFeatures {
32 pub log_returns: Vec<f64>,
34 pub realized_volatility: f64,
36 pub momentum: f64,
38 pub rsi: f64,
40 pub macd_signal: f64,
42 pub bollinger_position: f64,
44}
45
46impl PriceFeatures {
47 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 let log_returns: Vec<f64> = (1..n)
64 .map(|i| (closes[i] / closes[i - 1]).ln())
65 .collect();
66
67 let rv_slice = &log_returns[log_returns.len().saturating_sub(window)..];
69 let realized_volatility = std_dev(rv_slice);
70
71 let momentum = if n > window {
73 closes[n - 1] / closes[n - 1 - window] - 1.0
74 } else {
75 0.0
76 };
77
78 let rsi = compute_rsi(&log_returns, window);
80
81 let macd_signal = compute_macd_signal(closes);
83
84 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#[derive(Debug, Clone, Copy)]
185pub struct MicrostructureFeatures {
186 pub order_imbalance: f64,
188 pub trade_intensity: f64,
190 pub price_impact_coefficient: f64,
192}
193
194impl MicrostructureFeatures {
195 #[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#[derive(Debug, Clone)]
246pub struct FeatureVector {
247 pub names: Vec<String>,
249 pub values: Vec<f64>,
251}
252
253impl FeatureVector {
254 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 #[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 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 #[must_use]
284 pub fn len(&self) -> usize {
285 self.values.len()
286 }
287
288 #[must_use]
290 pub fn is_empty(&self) -> bool {
291 self.values.is_empty()
292 }
293}
294
295#[derive(Debug, Clone)]
313pub struct FeatureNormalizer {
314 mean: Option<f64>,
315 std: Option<f64>,
316}
317
318impl FeatureNormalizer {
319 #[must_use]
321 pub fn new() -> Self {
322 Self {
323 mean: None,
324 std: None,
325 }
326 }
327
328 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 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 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 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
390pub struct CrossSectionalRanker;
409
410impl CrossSectionalRanker {
411 #[must_use]
416 pub fn rank(values: &[f64]) -> Vec<f64> {
417 let n = values.len();
418 if n == 0 {
419 return vec![];
420 }
421 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#[derive(Debug, Clone)]
456pub struct LaggedFeatures {
457 pub lag1: Vec<f64>,
459 pub lag5: Vec<f64>,
461 pub lag10: Vec<f64>,
463 pub lag21: Vec<f64>,
465}
466
467impl LaggedFeatures {
468 #[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 #[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#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[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 #[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 #[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 #[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 #[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 #[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 assert!(lf.lag1[0].is_nan());
637 assert!((lf.lag1[1] - 1.0).abs() < 1e-10);
638 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}