Files
foxhunt/crates/ml/src/features/extraction.rs
jgrusewski daf771c38d audit: annotate all remaining to_vec/memcpy_dtoh — categorized 158 sites
Every to_vec()/memcpy_dtoh across 48 files audited and annotated:
- ~100 false positives: Rust slice .to_vec() (cpu-side, never touches GPU)
- ~25 gpu-exit: legitimate scalar readbacks (loss, grad_norm, epoch state)
- ~20 test-only readbacks: gated by #[cfg(test)] scope
- ~10 cpu-side uploads: .to_vec() before from_vec() GPU upload
- ~3 checkpoint exports: export_to_host at epoch boundary

Annotations use inline comments: // cpu-side, // gpu-exit:, // test-only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 19:36:41 +01:00

1313 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 42-Dimension Feature Extraction for ML Models
//!
//! This module implements streamlined feature engineering for HFT ML models,
//! extracting 42 features per OHLCV bar:
//! - 42 market features (40 base + 2 regime)
//! - 2 regime features (ADX trend strength, CUSUM direction) for GPU-native regime classification
//!
//! ## Performance
//! - Target: <1ms per bar for 42 features
//! - Memory: ~336 bytes per bar (42 × f64)
//! - Uses rolling windows (VecDeque) for O(1) amortized complexity
//!
//! ## Architecture
//! ```rust
//! use ml::features::extraction::extract_ml_features;
//! use ml::data_loader::RealDataLoader;
//!
//! let loader = RealDataLoader::new();
//! let bars = loader.load_ohlcv_bars("ES.FUT").await?;
//! let features = extract_ml_features(&bars)?; // Vec<[f64; 42]>
//! ```
use crate::features::microstructure::{AmihudIlliquidity, CorwinSchultzSpread, RollMeasure};
pub use crate::types::OHLCVBar;
use anyhow::{Context, Result};
use chrono::{Datelike, Timelike};
use common::features::{BollingerBands, ATR, EMA, MACD, RSI};
// WAVE 8 AGENT 37: Import Wave D feature modules
use crate::features::regime_adaptive::RegimeAdaptiveFeatures;
use crate::features::regime_adx::RegimeADXFeatures;
use crate::features::regime_cusum::RegimeCUSUMFeatures;
use crate::features::regime_transition::RegimeTransitionFeatures;
use std::collections::VecDeque;
// WAVE 4: Import OFI calculator for TRUE OFI features (8 features from MBP-10 data)
use crate::features::ofi_calculator::OFICalculator;
use data::providers::databento::mbp10::Mbp10Snapshot;
/// Feature extraction version for cache invalidation
///
/// Increment this version string ONLY when the feature extraction logic changes
/// in a way that affects the output. This is used by the caching system to
/// automatically invalidate stale caches.
///
/// Version History:
/// - 1.0.0: Initial 51-feature implementation (43 base + 8 OFI)
/// - 2.0.0: Remove dead features (3 regime + 8 OFI) → 42 market features (40 base + 2 regime: ADX, CUSUM)
/// - 3.0.0: Add ADX + CUSUM direction for GPU-native regime classification → 42 market features
pub const FEATURE_EXTRACTION_VERSION: &str = "3.0.0";
/// Feature extraction result: 42-dimensional feature vector per bar
///
/// Layout: OHLCV(5) + Technical(5) + Patterns(6) + Volume(6) + Time(5) + Statistical(13) + Regime(2) = 42
pub type FeatureVector = [f64; 42];
/// Main feature extraction function: Converts OHLCV bars to 42-dim feature vectors
///
/// ## Arguments
/// - `bars`: Input OHLCV bars from real data loader
///
/// ## Returns
/// - `Vec<FeatureVector>`: 42-dim feature vectors per bar (after warmup period)
///
/// ## Feature Breakdown (42 features)
/// - Features 0-4: OHLCV (5 features)
/// - Features 5-9: Technical indicators (5 features)
/// - Features 10-15: Price patterns (6 features)
/// - Features 16-21: Volume features (6 features)
/// - Features 22-26: Time-based features (5 features)
/// - Features 27-39: Statistical features (13 features)
/// - Feature 40: ADX(14) normalized [0,1] (regime trend strength)
/// - Feature 41: CUSUM direction [-1,1] (regime change direction)
///
/// ## Warmup Period
/// Requires minimum 50 bars for rolling windows. Returns feature vectors only after warmup.
pub fn extract_ml_features(bars: &[OHLCVBar]) -> Result<Vec<FeatureVector>> {
if bars.is_empty() {
anyhow::bail!("Cannot extract features from empty bar sequence");
}
const WARMUP_PERIOD: usize = 50;
if bars.len() < WARMUP_PERIOD {
anyhow::bail!(
"Insufficient data: {} bars provided, {} required for warmup",
bars.len(),
WARMUP_PERIOD
);
}
let mut extractor = FeatureExtractor::new();
let mut feature_vectors = Vec::with_capacity(bars.len() - WARMUP_PERIOD);
// Feed bars sequentially to build rolling windows
for (i, bar) in bars.iter().enumerate() {
extractor.update(bar)?;
// Start extracting features after warmup
if i >= WARMUP_PERIOD {
feature_vectors.push(extractor.extract_current_features()?);
}
}
Ok(feature_vectors)
}
/// Stateful feature extractor with rolling windows for O(1) amortized complexity
// WAVE 7 AGENT 29C: Made public to allow custom Wave C extraction in DQN trainer
#[derive(Debug)]
pub struct FeatureExtractor {
/// Rolling window of bars (max 260 for 52-week approximation)
bars: VecDeque<OHLCVBar>,
/// Technical indicator calculator (reuse from ml_training_service)
indicators: TechnicalIndicatorState,
/// Roll Measure (effective spread estimator)
roll_measure: RollMeasure,
/// Amihud Illiquidity (price impact measure)
amihud_illiquidity: AmihudIlliquidity,
/// Corwin-Schultz Spread (high-low volatility decomposition)
corwin_schultz_spread: CorwinSchultzSpread,
// WAVE 8 AGENT 37: Wave D feature extractors (indices 201-224, 24 features)
/// CUSUM regime detection features (indices 201-210, 10 features)
regime_cusum: RegimeCUSUMFeatures,
/// ADX directional indicators (indices 211-215, 5 features)
regime_adx: RegimeADXFeatures,
/// Transition probabilities (indices 216-220, 5 features)
regime_transition: RegimeTransitionFeatures,
/// Adaptive position/stop-loss metrics (indices 221-224, 4 features)
regime_adaptive: RegimeAdaptiveFeatures,
// WAVE 4: OFI calculator for TRUE OFI features (indices 46-53, 8 features)
/// Order Flow Imbalance calculator (stateful, requires MBP-10 data)
ofi_calculator: OFICalculator,
/// Last ADX(14) value from regime_adx extractor, range [0, 100]
last_adx: f64,
/// Last CUSUM direction from regime_cusum extractor, range [-1, 1]
last_cusum_direction: f64,
}
impl FeatureExtractor {
pub fn new() -> Self {
Self {
bars: VecDeque::with_capacity(260),
indicators: TechnicalIndicatorState::new(),
roll_measure: RollMeasure::new(),
amihud_illiquidity: AmihudIlliquidity::default(),
corwin_schultz_spread: CorwinSchultzSpread::new(),
// WAVE 8 AGENT 37: Initialize Wave D extractors
regime_cusum: RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 4.0),
regime_adx: RegimeADXFeatures::new(14),
regime_transition: RegimeTransitionFeatures::new(4, 0.1),
regime_adaptive: RegimeAdaptiveFeatures::new(20, 100_000.0, 14),
// WAVE 4: Initialize OFI calculator
ofi_calculator: OFICalculator::new(),
// Regime feature cache (populated by update(), consumed by extract_current_features_v2())
last_adx: 0.0,
last_cusum_direction: 0.0,
}
}
pub fn update(&mut self, bar: &OHLCVBar) -> Result<()> {
// Add to rolling window
self.bars.push_back(bar.clone());
if self.bars.len() > 260 {
self.bars.pop_front();
}
// Update technical indicators
self.indicators.update(bar)?;
// Update microstructure features
self.roll_measure.update(bar.close);
self.amihud_illiquidity.update(bar.close, bar.volume);
self.corwin_schultz_spread
.update(bar.high, bar.low, bar.close);
// Update regime feature extractors (ADX + CUSUM for GPU-native regime classification)
let adx_features = self.regime_adx.update(bar);
self.last_adx = adx_features[0]; // ADX(14) value, range [0, 100]
let close_return = if self.bars.len() > 1 {
let prev_close = self.bars[self.bars.len() - 2].close;
safe_log_return(bar.close, prev_close)
} else {
0.0
};
let cusum_features = self.regime_cusum.update(close_return);
self.last_cusum_direction = cusum_features[3]; // Direction: -1, 0, or +1
Ok(())
}
/// Extract 42 market features for the current bar state.
///
/// Returns FeatureVector ([f64; 42]) containing:
/// - OHLCV (5), Technical (5), Price Patterns (6), Volume (6)
/// - Time (5), Statistical (13), Regime (2: ADX, CUSUM direction)
pub fn extract_current_features_v2(&mut self) -> Result<FeatureVector> {
let mut features = [0.0; 42];
let mut offset = 0;
// OHLCV (5 features, indices 0-4)
self.extract_ohlcv_features(&mut features[offset..offset+5])?;
offset += 5;
// Technical (5 features, indices 5-9)
self.extract_technical_features_v2(&mut features[offset..offset+5])?;
offset += 5;
// Price Patterns (6 features, indices 10-15)
self.extract_price_patterns_v2(&mut features[offset..offset+6])?;
offset += 6;
// Volume (6 features, indices 16-21)
self.extract_volume_features_v2(&mut features[offset..offset+6])?;
offset += 6;
// Time (5 features, indices 22-26)
self.extract_time_features_v2(&mut features[offset..offset+5])?;
offset += 5;
// Statistical (13 features, indices 27-39)
self.extract_statistical_features_v2(&mut features[offset..offset+13])?;
offset += 13;
// Regime (2 features, indices 40-41)
features[offset] = safe_normalize(self.last_adx, 0.0, 100.0); // ADX [0,1]
offset += 1;
features[offset] = self.last_cusum_direction; // CUSUM direction [-1, 1]
offset += 1;
debug_assert_eq!(offset, 42, "Expected 42 core features, got {}", offset);
// Validate no NaN/Inf
self.validate_features(&features)?;
Ok(features)
}
/// Extract 42 market features (OFI features removed in Task 5.1).
///
/// MBP-10 snapshots are accepted for API compatibility but ignored.
/// OFI features were always zero-padded when MBP-10 data was unavailable
/// and have been removed as dead features.
///
/// # Arguments
/// * `mbp10_snapshots` - Ignored (kept for API compatibility)
///
/// # Returns
/// * `Result<FeatureVector>` - 42-element feature vector
pub fn extract_current_features_with_ofi(
&mut self,
_mbp10_snapshots: &[Mbp10Snapshot],
) -> Result<FeatureVector> {
// OFI features removed — just return the 42 market features
self.extract_current_features_v2()
}
/// Extract technical indicators (5) - Version 2 (REDUCED from 10)
///
/// Features 5-9:
/// - Index 5: RSI(14) normalized [0,1]
/// - Index 6: MACD histogram only (captures divergence)
/// - Index 7: Bollinger upper band
/// - Index 8: Bollinger lower band
/// - Index 9: ATR(14)
fn extract_technical_features_v2(&self, out: &mut [f64]) -> Result<()> {
let indicators = &self.indicators;
out[0] = safe_normalize(indicators.last_rsi, 0.0, 100.0); // RSI
out[1] = safe_clip(indicators.last_macd.2, -3.0, 3.0); // MACD histogram only
out[2] = safe_clip(indicators.last_bollinger.1, -3.0, 3.0); // BB upper
out[3] = safe_clip(indicators.last_bollinger.2, -3.0, 3.0); // BB lower
out[4] = safe_normalize(indicators.last_atr.max(0.001).ln(), -7.0, 9.0); // ATR log-normalized (ln(0.001)≈-6.9, ln(5000)≈8.5)
Ok(())
}
/// Extract price patterns (6) - Version 2 (REDUCED from 60)
///
/// Features 10-15:
/// - Index 10: Simple return (close to prev close)
/// - Index 11: Intraday return (close to open)
/// - Index 12: Overnight return (open to prev close)
/// - Index 13: Close to SMA(20) ratio
/// - Index 14: Close to SMA(50) ratio
/// - Index 15: Linear regression slope(20)
fn extract_price_patterns_v2(&self, out: &mut [f64]) -> Result<()> {
let bar = self.bars.back().context("No current bar")?;
let mut idx = 0;
// Returns (3)
if self.bars.len() > 1 {
let prev = &self.bars[self.bars.len() - 2];
out[idx] = safe_log_return(bar.close, prev.close); // Simple return
idx += 1;
out[idx] = safe_log_return(bar.close, bar.open); // Intraday return
idx += 1;
out[idx] = safe_log_return(bar.open, prev.close); // Overnight return
idx += 1;
} else {
idx += 3;
}
// Moving average ratios (2)
out[idx] = if self.bars.len() >= 20 {
let sma = self.compute_sma(20);
safe_clip((bar.close / sma) - 1.0, -0.5, 0.5)
} else {
0.0
};
idx += 1;
out[idx] = if self.bars.len() >= 50 {
let sma = self.compute_sma(50);
safe_clip((bar.close / sma) - 1.0, -0.5, 0.5)
} else {
0.0
};
idx += 1;
// Linear regression slope (1)
out[idx] = if self.bars.len() >= 20 {
let slope = self.compute_linear_regression_slope(20);
safe_clip(slope, -0.1, 0.1)
} else {
0.0
};
Ok(())
}
/// Extract volume features (6) - Version 2 (REDUCED from 40)
///
/// Features 16-21:
/// - Index 16: Volume ratio to SMA(20)
/// - Index 17: Volume spike indicator (>2x avg)
/// - Index 18: VWAP(20)
/// - Index 19: VWAP deviation
/// - Index 20: Price-volume product
/// - Index 21: Price-volume correlation(20)
fn extract_volume_features_v2(&self, out: &mut [f64]) -> Result<()> {
let bar = self.bars.back().context("No current bar")?;
let mut idx = 0;
// Volume ratio to SMA(20)
out[idx] = if self.bars.len() >= 20 {
let vol_sma = self.compute_volume_sma(20);
safe_clip((bar.volume / vol_sma) - 1.0, -2.0, 2.0)
} else {
0.0
};
idx += 1;
// Volume spike indicator
out[idx] = if self.bars.len() >= 5 {
let avg_vol = self.compute_volume_sma(5);
if bar.volume > avg_vol * 2.0 {
1.0
} else {
0.0
}
} else {
0.0
};
idx += 1;
// VWAP(20) - normalized as ratio to current close
out[idx] = if self.bars.len() >= 20 && bar.close > 0.0 {
let vwap = self.compute_vwap(20);
safe_clip((vwap / bar.close) - 1.0, -0.1, 0.1)
} else {
0.0
};
idx += 1;
// VWAP deviation
out[idx] = safe_clip(
(bar.close / (self.compute_vwap(20) + 1e-8)) - 1.0,
-0.1,
0.1,
);
idx += 1;
// Price-volume product
out[idx] = if self.bars.len() > 1 {
let ret = safe_log_return(bar.close, self.bars[self.bars.len() - 2].close);
ret * safe_normalize(bar.volume.max(1.0).ln(), 0.0, 18.0)
} else {
0.0
};
idx += 1;
// Price-volume correlation(20)
out[idx] = self.compute_price_volume_correlation(20);
Ok(())
}
/// Extract time-based features (5) - Version 2 (REDUCED from 10)
///
/// Features 22-26:
/// - Index 22: Hour of day (0-23 normalized)
/// - Index 23: Day of week (0-6 normalized)
/// - Index 24: Is market open (binary)
/// - Index 25: Minutes since open
/// - Index 26: Minutes to close
fn extract_time_features_v2(&self, out: &mut [f64]) -> Result<()> {
let bar = self.bars.back().context("No current bar")?;
let dt = bar.timestamp;
out[0] = safe_normalize(dt.hour() as f64, 0.0, 23.0);
out[1] = safe_normalize(dt.weekday().num_days_from_monday() as f64, 0.0, 6.0);
out[2] = if dt.hour() >= 9 && dt.hour() < 16 {
1.0
} else {
0.0
};
out[3] = safe_normalize(
(dt.hour() as f64 - 9.0) * 60.0 + dt.minute() as f64,
0.0,
420.0,
);
out[4] = safe_normalize(
(16.0 - dt.hour() as f64) * 60.0 - dt.minute() as f64,
0.0,
420.0,
);
Ok(())
}
/// Extract statistical features (13) - Version 2 (REDUCED from 26)
///
/// Features 27-39:
/// - Indices 27-28: Z-score(10), Z-score(20)
/// - Indices 29-30: Percentile(10), Percentile(20)
/// - Indices 31-33: Autocorr lag-1, lag-5, lag-10
/// - Indices 34-36: Skewness(5), Skewness(10), Skewness(20)
/// - Indices 37-39: Kurtosis(5), Kurtosis(10), Kurtosis(20)
fn extract_statistical_features_v2(&self, out: &mut [f64]) -> Result<()> {
let bar = self.bars.back().context("No current bar")?;
let mut idx = 0;
// Z-scores (2)
for period in [10, 20] {
if self.bars.len() >= period {
let mean = self.compute_sma(period);
let std = self.compute_std(period);
out[idx] = safe_clip((bar.close - mean) / (std + 1e-8), -3.0, 3.0);
idx += 1;
} else {
idx += 1;
}
}
// Percentile ranks (2)
for period in [10, 20] {
if self.bars.len() >= period {
let min = self.compute_min(period);
let max = self.compute_max(period);
out[idx] = safe_clip((bar.close - min) / (max - min + 1e-8), 0.0, 1.0);
idx += 1;
} else {
idx += 1;
}
}
// Autocorrelations (3)
for lag in [1, 5, 10] {
out[idx] = if self.bars.len() > lag {
self.compute_autocorr(lag)
} else {
0.0
};
idx += 1;
}
// Skewness (3)
for period in [5, 10, 20] {
out[idx] = self.compute_skewness(period);
idx += 1;
}
// Kurtosis (3)
for period in [5, 10, 20] {
out[idx] = self.compute_kurtosis(period);
idx += 1;
}
Ok(())
}
// extract_regime_features_v2 removed (Task 5.1): always returned zeros
/// Extract current 42-dim market features.
///
/// Returns the same result as extract_current_features_v2().
pub fn extract_current_features(&mut self) -> Result<FeatureVector> {
self.extract_current_features_v2()
}
/// Extract OHLCV features (5): Normalized raw price/volume data
fn extract_ohlcv_features(&self, out: &mut [f64]) -> Result<()> {
let bar = self.bars.back().context("No current bar")?;
// Normalize using log returns and volume ratio
let prev_close = if self.bars.len() > 1 {
self.bars[self.bars.len() - 2].close
} else {
bar.close
};
out[0] = safe_log_return(bar.open, prev_close); // Open relative to prev close
out[1] = safe_log_return(bar.high, prev_close); // High relative to prev close
out[2] = safe_log_return(bar.low, prev_close); // Low relative to prev close
out[3] = safe_log_return(bar.close, prev_close); // Close return
out[4] = safe_normalize(bar.volume.max(1.0).ln(), 0.0, 18.0); // Volume log-normalized (ln(1)=0, ln(10M)≈16.1)
Ok(())
}
/// Validate no NaN/Inf in feature vector
fn validate_features(&self, features: &[f64]) -> Result<()> {
for (i, &val) in features.iter().enumerate() {
if !val.is_finite() {
anyhow::bail!("Invalid feature at index {}: {}", i, val);
}
}
Ok(())
}
// ===== Helper Methods =====
fn compute_sma(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
let sum: f64 = self.bars.iter().skip(start).map(|b| b.close).sum();
sum / period as f64
}
fn compute_std(&self, period: usize) -> f64 {
let mean = self.compute_sma(period);
let start = self.bars.len().saturating_sub(period);
let variance: f64 = self
.bars
.iter()
.skip(start)
.map(|b| (b.close - mean).powi(2))
.sum::<f64>()
/ period as f64;
variance.sqrt()
}
fn compute_min(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
self.bars
.iter()
.skip(start)
.map(|b| b.close)
.fold(f64::INFINITY, f64::min)
}
fn compute_max(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
self.bars
.iter()
.skip(start)
.map(|b| b.close)
.fold(f64::NEG_INFINITY, f64::max)
}
fn compute_median(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
let mut values: Vec<f64> = self.bars.iter().skip(start).map(|b| b.close).collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
values[values.len() / 2]
}
fn compute_volume_sma(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
let sum: f64 = self.bars.iter().skip(start).map(|b| b.volume).sum();
sum / period as f64
}
fn compute_volume_std(&self, period: usize) -> f64 {
let mean = self.compute_volume_sma(period);
let start = self.bars.len().saturating_sub(period);
let variance: f64 = self
.bars
.iter()
.skip(start)
.map(|b| (b.volume - mean).powi(2))
.sum::<f64>()
/ period as f64;
variance.sqrt()
}
fn compute_vwap(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
let (weighted_sum, volume_sum): (f64, f64) = self
.bars
.iter()
.skip(start)
.map(|b| (b.close * b.volume, b.volume))
.fold((0.0, 0.0), |(ws, vs), (w, v)| (ws + w, vs + v));
weighted_sum / (volume_sum + 1e-8)
}
fn compute_momentum(&self, period: usize) -> f64 {
if self.bars.len() > period {
let curr = match self.bars.back() {
Some(b) => b.close,
None => return 0.0,
};
let prev = self.bars[self.bars.len() - period - 1].close;
if prev <= 0.0 || !prev.is_finite() {
return 0.0;
}
let momentum = (curr - prev) / prev;
if !momentum.is_finite() {
return 0.0;
}
momentum
} else {
0.0
}
}
fn compute_linear_regression_slope(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let start = self.bars.len() - period;
let n = period as f64;
let sum_x = (n * (n - 1.0)) / 2.0; // 0 + 1 + ... + (n-1)
let sum_x2 = (n * (n - 1.0) * (2.0 * n - 1.0)) / 6.0; // Sum of squares
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
for (i, bar) in self.bars.iter().skip(start).enumerate() {
sum_y += bar.close;
sum_xy += i as f64 * bar.close;
}
(n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x)
}
fn compute_autocorr(&self, lag: usize) -> f64 {
if self.bars.len() <= lag {
return 0.0;
}
let n = self.bars.len() - lag;
let mean: f64 = self.bars.iter().map(|b| b.close).sum::<f64>() / self.bars.len() as f64;
let mut numerator = 0.0;
let mut denominator = 0.0;
for i in 0..n {
numerator += (self.bars[i].close - mean) * (self.bars[i + lag].close - mean);
}
for bar in self.bars.iter() {
denominator += (bar.close - mean).powi(2);
}
numerator / (denominator + 1e-8)
}
// Missing helper methods implementation
fn compute_distance_to_high(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let max = self.compute_max(period);
let current = match self.bars.back() {
Some(b) => b.close,
None => return 0.0,
};
safe_clip((current - max) / current, -0.5, 0.0)
}
fn compute_distance_to_low(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let min = self.compute_min(period);
let current = match self.bars.back() {
Some(b) => b.close,
None => return 0.0,
};
safe_clip((current - min) / current, 0.0, 0.5)
}
fn compute_percentile_rank(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.5;
}
let current = match self.bars.back() {
Some(b) => b.close,
None => return 0.5,
};
let start = self.bars.len().saturating_sub(period);
let count_below = self
.bars
.iter()
.skip(start)
.filter(|b| b.close < current)
.count();
count_below as f64 / period as f64
}
fn compute_consecutive_highs(&self) -> f64 {
let mut count = 0;
if self.bars.len() < 2 {
return 0.0;
}
for i in (0..self.bars.len() - 1).rev() {
if self.bars[i + 1].close > self.bars[i].close {
count += 1;
} else {
break;
}
}
safe_normalize(count as f64, 0.0, 10.0)
}
fn compute_consecutive_lows(&self) -> f64 {
let mut count = 0;
if self.bars.len() < 2 {
return 0.0;
}
for i in (0..self.bars.len() - 1).rev() {
if self.bars[i + 1].close < self.bars[i].close {
count += 1;
} else {
break;
}
}
safe_normalize(count as f64, 0.0, 10.0)
}
fn compute_trend_quality(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let slope = self.compute_linear_regression_slope(period);
let std = self.compute_std(period);
let mean = self.compute_sma(period);
safe_clip(slope.abs() / (std / mean + 1e-8), 0.0, 1.0)
}
fn compute_roc(&self, period: usize) -> f64 {
if self.bars.len() <= period {
return 0.0;
}
let current = match self.bars.back() {
Some(b) => b.close,
None => return 0.0,
};
let prev = self.bars[self.bars.len() - period - 1].close;
if prev <= 0.0 || !prev.is_finite() {
return 0.0;
}
let roc = (current - prev) / prev;
safe_clip(roc, -0.5, 0.5)
}
fn compute_price_acceleration(&self) -> f64 {
if self.bars.len() < 3 {
return 0.0;
}
let curr = match self.bars.back() {
Some(b) => b.close,
None => return 0.0,
};
let prev1 = self.bars[self.bars.len() - 2].close;
let prev2 = self.bars[self.bars.len() - 3].close;
let vel1 = curr - prev1;
let vel2 = prev1 - prev2;
safe_clip(vel1 - vel2, -1.0, 1.0)
}
fn compute_price_velocity(&self) -> f64 {
if self.bars.len() < 2 {
return 0.0;
}
let curr = match self.bars.back() {
Some(b) => b.close,
None => return 0.0,
};
let prev = self.bars[self.bars.len() - 2].close;
safe_clip(curr - prev, -1.0, 1.0)
}
fn compute_body_ratio(&self) -> f64 {
let bar = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let body = (bar.close - bar.open).abs();
let range = bar.high - bar.low + 1e-8;
safe_clip(body / range, 0.0, 1.0)
}
fn compute_upper_shadow_ratio(&self) -> f64 {
let bar = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let upper_shadow = bar.high - bar.close.max(bar.open);
let range = bar.high - bar.low + 1e-8;
safe_clip(upper_shadow / range, 0.0, 1.0)
}
fn compute_lower_shadow_ratio(&self) -> f64 {
let bar = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let lower_shadow = bar.close.min(bar.open) - bar.low;
let range = bar.high - bar.low + 1e-8;
safe_clip(lower_shadow / range, 0.0, 1.0)
}
fn compute_doji_indicator(&self) -> f64 {
let bar = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let body = (bar.close - bar.open).abs();
let range = bar.high - bar.low + 1e-8;
if body / range < 0.1 {
1.0
} else {
0.0
}
}
fn compute_hammer_indicator(&self) -> f64 {
let bar = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let body = (bar.close - bar.open).abs();
let lower_shadow = bar.close.min(bar.open) - bar.low;
let range = bar.high - bar.low + 1e-8;
if lower_shadow > body * 2.0 && body / range > 0.1 {
1.0
} else {
0.0
}
}
fn compute_engulfing_indicator(&self) -> f64 {
if self.bars.len() < 2 {
return 0.0;
}
let curr = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let prev = &self.bars[self.bars.len() - 2];
let curr_body = (curr.close - curr.open).abs();
let prev_body = (prev.close - prev.open).abs();
if curr_body > prev_body * 1.5 {
1.0
} else {
0.0
}
}
fn compute_gap_indicator(&self) -> f64 {
if self.bars.len() < 2 {
return 0.0;
}
let curr = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let prev = &self.bars[self.bars.len() - 2];
if prev.close <= 0.0 || !prev.close.is_finite() {
return 0.0;
}
let gap = curr.open - prev.close;
safe_clip(gap / prev.close, -0.05, 0.05)
}
fn compute_range_position(&self) -> f64 {
let bar = match self.bars.back() {
Some(b) => b,
None => return 0.0,
};
let range = bar.high - bar.low + 1e-8;
safe_clip((bar.close - bar.low) / range, 0.0, 1.0)
}
fn compute_volume_momentum(&self, period: usize) -> f64 {
if self.bars.len() <= period {
return 0.0;
}
let curr_vol = match self.bars.back() {
Some(b) => b.volume,
None => return 0.0,
};
let prev_vol = self.bars[self.bars.len() - period - 1].volume;
safe_clip((curr_vol - prev_vol) / (prev_vol + 1e-8), -1.0, 1.0)
}
fn compute_volume_acceleration(&self) -> f64 {
if self.bars.len() < 3 {
return 0.0;
}
let curr = match self.bars.back() {
Some(b) => b.volume,
None => return 0.0,
};
let prev1 = self.bars[self.bars.len() - 2].volume;
let prev2 = self.bars[self.bars.len() - 3].volume;
let vel1 = curr - prev1;
let vel2 = prev1 - prev2;
safe_clip(vel1 - vel2, -100.0, 100.0)
}
fn compute_volume_max(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
self.bars
.iter()
.skip(start)
.map(|b| b.volume)
.fold(f64::NEG_INFINITY, f64::max)
}
fn compute_volume_min(&self, period: usize) -> f64 {
let start = self.bars.len().saturating_sub(period);
self.bars
.iter()
.skip(start)
.map(|b| b.volume)
.fold(f64::INFINITY, f64::min)
}
fn compute_up_down_volume_ratio(&self, period: usize) -> f64 {
if self.bars.len() < period + 1 {
return 0.5;
}
let start = self.bars.len().saturating_sub(period);
let mut up_vol = 0.0;
let mut down_vol = 0.0;
for i in start..self.bars.len() {
if i > 0 {
if self.bars[i].close > self.bars[i - 1].close {
up_vol += self.bars[i].volume;
} else if self.bars[i].close < self.bars[i - 1].close {
down_vol += self.bars[i].volume;
} else {
// Unchanged close, volume not attributed to either side
}
}
}
safe_clip(up_vol / (up_vol + down_vol + 1e-8), 0.0, 1.0)
}
fn compute_obv_momentum(&self, period: usize) -> f64 {
if self.bars.len() < period + 1 {
return 0.0;
}
let mut obv = 0.0;
let start = self.bars.len().saturating_sub(period);
for i in (start + 1)..self.bars.len() {
if self.bars[i].close > self.bars[i - 1].close {
obv += self.bars[i].volume;
} else if self.bars[i].close < self.bars[i - 1].close {
obv -= self.bars[i].volume;
} else {
// Unchanged close, OBV unchanged
}
}
safe_clip(obv / 1_000_000.0, -1.0, 1.0)
}
fn compute_volume_percentile(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.5;
}
let current_vol = match self.bars.back() {
Some(b) => b.volume,
None => return 0.5,
};
let start = self.bars.len().saturating_sub(period);
let count_below = self
.bars
.iter()
.skip(start)
.filter(|b| b.volume < current_vol)
.count();
count_below as f64 / period as f64
}
fn compute_price_volume_correlation(&self, period: usize) -> f64 {
if self.bars.len() < period + 1 {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let returns: Vec<f64> = (start + 1..self.bars.len())
.map(|i| safe_log_return(self.bars[i].close, self.bars[i - 1].close))
.collect();
let volumes: Vec<f64> = self.bars.iter().skip(start + 1).map(|b| b.volume).collect();
self.compute_correlation_from_vecs(&returns, &volumes)
}
fn compute_volume_weighted_returns(&self, period: usize) -> f64 {
if self.bars.len() < period + 1 {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let mut weighted_return = 0.0;
let mut total_vol = 0.0;
for i in (start + 1)..self.bars.len() {
let ret = safe_log_return(self.bars[i].close, self.bars[i - 1].close);
weighted_return += ret * self.bars[i].volume;
total_vol += self.bars[i].volume;
}
safe_clip(weighted_return / (total_vol + 1e-8), -0.1, 0.1)
}
fn compute_range_volume_correlation(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let ranges: Vec<f64> = self
.bars
.iter()
.skip(start)
.map(|b| (b.high - b.low) / b.close)
.collect();
let volumes: Vec<f64> = self.bars.iter().skip(start).map(|b| b.volume).collect();
self.compute_correlation_from_vecs(&ranges, &volumes)
}
fn compute_correlation_from_vecs(&self, x: &[f64], y: &[f64]) -> f64 {
if x.len() != y.len() || x.is_empty() {
return 0.0;
}
let n = x.len() as f64;
let mean_x: f64 = x.iter().sum::<f64>() / n;
let mean_y: f64 = y.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var_x = 0.0;
let mut var_y = 0.0;
for i in 0..x.len() {
let dx = x[i] - mean_x;
let dy = y[i] - mean_y;
cov += dx * dy;
var_x += dx * dx;
var_y += dy * dy;
}
let denom = (var_x * var_y).sqrt();
if denom > 1e-8 {
safe_clip(cov / denom, -1.0, 1.0)
} else {
0.0
}
}
fn compute_skewness(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let mean = self.compute_sma(period);
let std = self.compute_std(period);
if std < 1e-8 {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let skew: f64 = self
.bars
.iter()
.skip(start)
.map(|b| ((b.close - mean) / std).powi(3))
.sum::<f64>()
/ period as f64;
safe_clip(skew, -3.0, 3.0)
}
fn compute_kurtosis(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let mean = self.compute_sma(period);
let std = self.compute_std(period);
if std < 1e-8 {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let kurt: f64 = self
.bars
.iter()
.skip(start)
.map(|b| ((b.close - mean) / std).powi(4))
.sum::<f64>()
/ period as f64;
safe_clip(kurt - 3.0, -3.0, 3.0) // Excess kurtosis
}
fn compute_percentile(&self, values: &[f64], percentile: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec(); // cpu-side sort copy
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let index = ((sorted.len() as f64 - 1.0) * percentile) as usize;
sorted[index.min(sorted.len() - 1)]
}
fn compute_realized_volatility(&self, period: usize) -> f64 {
if self.bars.len() < period + 1 {
return 0.0;
}
let start = self.bars.len().saturating_sub(period + 1);
let returns: Vec<f64> = (start + 1..self.bars.len())
.map(|i| safe_log_return(self.bars[i].close, self.bars[i - 1].close))
.collect();
let mean = returns.iter().sum::<f64>() / returns.len() as f64;
let variance =
returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64;
variance.sqrt()
}
fn compute_parkinson_volatility(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let sum: f64 = self
.bars
.iter()
.skip(start)
.map(|b| {
let hl_ratio = (b.high / b.low).ln();
hl_ratio * hl_ratio
})
.sum();
(sum / (4.0 * period as f64 * (2.0_f64).ln())).sqrt()
}
fn compute_garman_klass_volatility(&self, period: usize) -> f64 {
if self.bars.len() < period {
return 0.0;
}
let start = self.bars.len().saturating_sub(period);
let sum: f64 = self
.bars
.iter()
.skip(start)
.map(|b| {
let hl = ((b.high / b.low).ln()).powi(2);
let co = ((b.close / b.open).ln()).powi(2);
0.5 * hl - (2.0 * (2.0_f64).ln() - 1.0) * co
})
.sum();
(sum / period as f64).sqrt()
}
}
/// Technical indicator state using common::features shared library
#[derive(Debug)]
struct TechnicalIndicatorState {
rsi: RSI,
ema_fast: EMA,
ema_slow: EMA,
macd: MACD,
bollinger: BollingerBands,
atr: ATR,
// Cache last computed values
last_rsi: f64,
last_ema_fast: f64,
last_ema_slow: f64,
last_macd: (f64, f64, f64),
last_bollinger: (f64, f64, f64),
last_atr: f64,
}
impl TechnicalIndicatorState {
fn new() -> Self {
Self {
rsi: RSI::new(14),
ema_fast: EMA::new(12),
ema_slow: EMA::new(26),
macd: MACD::new(12, 26, 9),
bollinger: BollingerBands::new(20, 2.0),
atr: ATR::new(14),
last_rsi: 50.0,
last_ema_fast: 0.0,
last_ema_slow: 0.0,
last_macd: (0.0, 0.0, 0.0),
last_bollinger: (0.0, 0.0, 0.0),
last_atr: 0.0,
}
}
fn update(&mut self, bar: &OHLCVBar) -> Result<()> {
// Update all indicators with new bar data and cache results
self.last_rsi = self.rsi.update(bar.close);
self.last_ema_fast = self.ema_fast.update(bar.close);
self.last_ema_slow = self.ema_slow.update(bar.close);
self.last_macd = self.macd.update(bar.close);
self.last_bollinger = self.bollinger.update(bar.close);
self.last_atr = self.atr.update(bar.high, bar.low, bar.close);
Ok(())
}
}
// ===== Utility Functions =====
/// Safe log return: log(current / previous), handles edge cases
fn safe_log_return(current: f64, previous: f64) -> f64 {
if previous <= 0.0 || current <= 0.0 {
return 0.0;
}
let ratio = current / previous;
if ratio <= 0.0 || !ratio.is_finite() {
return 0.0;
}
ratio.ln()
}
/// Safe normalization: (value - min) / (max - min), clipped to [0, 1]
fn safe_normalize(value: f64, min: f64, max: f64) -> f64 {
if max <= min || !value.is_finite() {
return 0.0;
}
let normalized = (value - min) / (max - min);
normalized.clamp(0.0, 1.0)
}
/// Safe clipping: Clip value to [min, max] range
fn safe_clip(value: f64, min: f64, max: f64) -> f64 {
if !value.is_finite() {
return 0.0;
}
value.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_feature_extraction_dimensions() {
// Create synthetic bars
let bars: Vec<OHLCVBar> = (0..100)
.map(|i| OHLCVBar {
timestamp: chrono::Utc::now() + chrono::Duration::hours(i),
open: 100.0 + i as f64 * 0.1,
high: 101.0 + i as f64 * 0.1,
low: 99.0 + i as f64 * 0.1,
close: 100.5 + i as f64 * 0.1,
volume: 1000.0 + i as f64 * 10.0,
})
.collect();
let features = extract_ml_features(&bars).unwrap();
// Should return features for bars after warmup (100 - 50 = 50)
assert_eq!(features.len(), 50);
// Each feature vector should be 42-dimensional (40 base + 2 regime: ADX, CUSUM direction)
for feature_vec in &features {
assert_eq!(feature_vec.len(), 42);
// Validate no NaN/Inf
for &val in feature_vec.iter() {
assert!(val.is_finite(), "Found non-finite value: {}", val);
}
}
}
#[test]
fn test_insufficient_data() {
let bars: Vec<OHLCVBar> = (0..10)
.map(|i| OHLCVBar {
timestamp: chrono::Utc::now() + chrono::Duration::hours(i),
open: 100.0,
high: 101.0,
low: 99.0,
close: 100.5,
volume: 1000.0,
})
.collect();
let result = extract_ml_features(&bars);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Insufficient data"));
}
#[test]
fn test_safe_log_return() {
assert_eq!(safe_log_return(110.0, 100.0), (1.1_f64).ln());
assert_eq!(safe_log_return(0.0, 100.0), 0.0); // Zero current
assert_eq!(safe_log_return(100.0, 0.0), 0.0); // Zero previous
assert_eq!(safe_log_return(-10.0, 100.0), 0.0); // Negative
}
#[test]
fn test_safe_normalize() {
assert_eq!(safe_normalize(50.0, 0.0, 100.0), 0.5);
assert_eq!(safe_normalize(150.0, 0.0, 100.0), 1.0); // Clipped to 1.0
assert_eq!(safe_normalize(-50.0, 0.0, 100.0), 0.0); // Clipped to 0.0
assert_eq!(safe_normalize(f64::NAN, 0.0, 100.0), 0.0); // NaN handling
}
}