Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
400 lines
14 KiB
Rust
400 lines
14 KiB
Rust
//! Features Module - Core Feature Engineering
|
|
//!
|
|
//! This module provides the unified feature extraction system that ensures
|
|
//! zero training/serving skew across all ML models and trading stages.
|
|
//!
|
|
//! ## Key Components
|
|
//!
|
|
//! - `UnifiedFeatureExtractor`: Single source of truth for all feature calculations
|
|
//! - Model-specific feature sets: TLOB, MAMBA, DQN, PPO, Liquid, TFT
|
|
//! - Data provider integration: Databento (market data) + Benzinga (news/sentiment)
|
|
//! - High-performance SIMD optimizations for real-time processing
|
|
//!
|
|
//! ## Architecture Principles
|
|
//!
|
|
//! 1. **Single Source of Truth**: All features calculated identically across:
|
|
//! - Training: Historical data processing
|
|
//! - Backtesting: Strategy validation
|
|
//! - Live Trading: Real-time inference
|
|
//!
|
|
//! 2. **Data Provider Separation**:
|
|
//! - Databento: Market microstructure (trades, quotes, order books)
|
|
//! - Benzinga: News sentiment, analyst ratings, unusual options
|
|
//!
|
|
//! 3. **Model-Specific Features**:
|
|
//! - TLOB: Order book sequences for transformer analysis
|
|
//! - MAMBA: Long sequences for state space modeling
|
|
//! - DQN: State representation for reinforcement learning
|
|
//! - PPO: Policy-specific features with advantage estimation
|
|
//! - Liquid: Adaptive features for regime detection
|
|
//! - TFT: Multi-horizon sequences with attention inputs
|
|
//!
|
|
//! ## Usage Example
|
|
//!
|
|
//! ``rust
|
|
//! use core::features::{UnifiedFeatureExtractor, UnifiedConfig};
|
|
//!
|
|
//! let config = UnifiedConfig::default();
|
|
//! let mut extractor = UnifiedFeatureExtractor::new(config);
|
|
//!
|
|
//! // Extract TLOB features for transformer model
|
|
//! let tlob_features = extractor.extract_tlob_features(
|
|
//! &symbol,
|
|
//! &databento_data,
|
|
//! &benzinga_data,
|
|
//! &historical_data
|
|
//! ).await?;
|
|
//!
|
|
//! // Extract DQN features for reinforcement learning
|
|
//! let dqn_features = extractor.extract_dqn_features(
|
|
//! &symbol,
|
|
//! &databento_data,
|
|
//! &benzinga_data,
|
|
//! &historical_data,
|
|
//! current_position,
|
|
//! unrealized_pnl
|
|
//! ).await?;
|
|
//! ``
|
|
//!
|
|
//! ## Performance Characteristics
|
|
//!
|
|
//! - **Latency**: Sub-millisecond feature extraction via SIMD
|
|
//! - **Throughput**: 10,000+ symbols processed per second
|
|
//! - **Memory**: Efficient caching with configurable TTL
|
|
//! - **Accuracy**: Identical calculations across all environments
|
|
|
|
pub mod unified_extractor;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
// Types are NOT re-exported - use explicit imports at usage sites:
|
|
// use crate::features::unified_extractor::{...};
|
|
use unified_extractor::{
|
|
BaseMarketFeatures, BenzingaNewsData, BenzingaNewsFeatures, DatabentoBuData,
|
|
DatabentoBuFeatures, FeatureError, UnifiedFeatureExtractor,
|
|
};
|
|
|
|
/// Feature extraction result type for ergonomic error handling
|
|
pub type FeatureResult<T> = Result<T, FeatureError>;
|
|
|
|
/// Trait for model-specific feature extraction
|
|
#[async_trait]
|
|
/// ModelFeatureExtractor
|
|
///
|
|
/// Auto-generated documentation placeholder - enhance with specifics
|
|
pub trait ModelFeatureExtractor<F> {
|
|
/// Extract features specific to this model type
|
|
async fn extract_features(
|
|
&mut self,
|
|
extractor: &mut UnifiedFeatureExtractor,
|
|
symbol: &common::types::Symbol,
|
|
databento_data: &DatabentoBuData,
|
|
benzinga_data: &BenzingaNewsData,
|
|
historical_data: &[common::types::MarketTick],
|
|
) -> FeatureResult<F>;
|
|
}
|
|
|
|
/// Convenience macros for feature extraction
|
|
#[macro_export]
|
|
macro_rules! extract_features {
|
|
($extractor:expr, $model:ident, $symbol:expr, $databento:expr, $benzinga:expr, $historical:expr) => {
|
|
$extractor.paste::paste! {
|
|
[<extract_ $model:lower _features>]
|
|
}($symbol, $databento, $benzinga, $historical).await
|
|
};
|
|
|
|
($extractor:expr, $model:ident, $symbol:expr, $databento:expr, $benzinga:expr, $historical:expr, $($extra:expr),+) => {
|
|
$extractor.paste::paste! {
|
|
[<extract_ $model:lower _features>]
|
|
}($symbol, $databento, $benzinga, $historical, $($extra),+).await
|
|
};
|
|
}
|
|
|
|
/// Feature validation utilities
|
|
pub mod validation {
|
|
use super::{
|
|
BaseMarketFeatures, BenzingaNewsFeatures, DatabentoBuFeatures, FeatureError, FeatureResult,
|
|
};
|
|
|
|
/// Validate feature quality and completeness
|
|
pub fn validate_base_features(features: &BaseMarketFeatures) -> FeatureResult<()> {
|
|
// Check for NaN/Inf values
|
|
if !features.returns_1m.is_finite() {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "returns_1m".to_owned(),
|
|
reason: "Non-finite value detected".to_owned(),
|
|
});
|
|
}
|
|
|
|
// Validate ranges
|
|
if features.rsi_14 < 0.0 || features.rsi_14 > 100.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "rsi_14".to_owned(),
|
|
reason: format!("RSI out of range: {}", features.rsi_14),
|
|
});
|
|
}
|
|
|
|
// Check bollinger position is within reasonable bounds
|
|
if features.bollinger_position < -5.0 || features.bollinger_position > 5.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "bollinger_position".to_owned(),
|
|
reason: format!(
|
|
"Bollinger position extreme: {}",
|
|
features.bollinger_position
|
|
),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate Databento features
|
|
pub fn validate_databento_features(features: &DatabentoBuFeatures) -> FeatureResult<()> {
|
|
// Spread should be positive
|
|
if features.bid_ask_spread_bps < 0.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "bid_ask_spread_bps".to_owned(),
|
|
reason: "Negative spread detected".to_owned(),
|
|
});
|
|
}
|
|
|
|
// Order book imbalance should be in [-1, 1]
|
|
if features.order_book_imbalance < -1.0 || features.order_book_imbalance > 1.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "order_book_imbalance".to_owned(),
|
|
reason: format!("Imbalance out of range: {}", features.order_book_imbalance),
|
|
});
|
|
}
|
|
|
|
// Trade sign should be -1, 0, or 1
|
|
if ![-1, 0, 1].contains(&features.trade_sign) {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "trade_sign".to_owned(),
|
|
reason: format!("Invalid trade sign: {}", features.trade_sign),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate Benzinga sentiment features
|
|
pub fn validate_benzinga_features(features: &BenzingaNewsFeatures) -> FeatureResult<()> {
|
|
// Sentiment score should be in [-1, 1]
|
|
if features.sentiment_score < -1.0 || features.sentiment_score > 1.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "sentiment_score".to_owned(),
|
|
reason: format!("Sentiment out of range: {}", features.sentiment_score),
|
|
});
|
|
}
|
|
|
|
// Confidence should be in [0, 1]
|
|
if features.sentiment_confidence < 0.0 || features.sentiment_confidence > 1.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "sentiment_confidence".to_owned(),
|
|
reason: format!("Confidence out of range: {}", features.sentiment_confidence),
|
|
});
|
|
}
|
|
|
|
// News velocity should be non-negative
|
|
if features.news_velocity < 0.0 {
|
|
return Err(FeatureError::MathematicalError {
|
|
feature: "news_velocity".to_owned(),
|
|
reason: "Negative news velocity".to_owned(),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Performance monitoring for feature extraction
|
|
pub mod monitoring {
|
|
use std::collections::HashMap;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Feature extraction performance metrics
|
|
#[derive(Debug, Clone)]
|
|
pub struct FeatureMetrics {
|
|
/// Extraction Time
|
|
pub extraction_time: Duration,
|
|
/// Feature Count
|
|
pub feature_count: usize,
|
|
/// Cache Hits
|
|
pub cache_hits: usize,
|
|
/// Cache Misses
|
|
pub cache_misses: usize,
|
|
/// Validation Time
|
|
pub validation_time: Duration,
|
|
}
|
|
|
|
/// Performance monitor for feature extraction
|
|
#[derive(Debug)]
|
|
pub struct FeatureMonitor {
|
|
metrics: HashMap<String, Vec<FeatureMetrics>>,
|
|
start_times: HashMap<String, Instant>,
|
|
}
|
|
|
|
impl FeatureMonitor {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
metrics: HashMap::new(),
|
|
start_times: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Start timing a feature extraction operation
|
|
pub fn start_timing(&mut self, operation: &str) {
|
|
self.start_times
|
|
.insert(operation.to_owned(), Instant::now());
|
|
}
|
|
|
|
/// End timing and record metrics
|
|
pub fn end_timing(
|
|
&mut self,
|
|
operation: &str,
|
|
feature_count: usize,
|
|
cache_hits: usize,
|
|
cache_misses: usize,
|
|
) {
|
|
if let Some(start_time) = self.start_times.remove(operation) {
|
|
let extraction_time = start_time.elapsed();
|
|
let metrics = FeatureMetrics {
|
|
extraction_time,
|
|
feature_count,
|
|
cache_hits,
|
|
cache_misses,
|
|
validation_time: Duration::from_nanos(0), // Set by validation
|
|
};
|
|
|
|
self.metrics
|
|
.entry(operation.to_owned())
|
|
.or_default()
|
|
.push(metrics);
|
|
}
|
|
}
|
|
|
|
/// Get average extraction time for an operation
|
|
pub fn average_extraction_time(&self, operation: &str) -> Option<Duration> {
|
|
self.metrics.get(operation).and_then(|metrics| {
|
|
if metrics.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let total: Duration = metrics.iter().map(|m| m.extraction_time).sum();
|
|
Some(total / metrics.len() as u32)
|
|
})
|
|
}
|
|
|
|
/// Get cache hit rate for an operation
|
|
pub fn cache_hit_rate(&self, operation: &str) -> Option<f64> {
|
|
self.metrics.get(operation).and_then(|metrics| {
|
|
if metrics.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let total_hits: usize = metrics.iter().map(|m| m.cache_hits).sum();
|
|
let total_requests: usize =
|
|
metrics.iter().map(|m| m.cache_hits + m.cache_misses).sum();
|
|
|
|
if total_requests == 0 {
|
|
// None variant
|
|
None
|
|
} else {
|
|
// Some variant
|
|
Some(total_hits as f64 / total_requests as f64)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for FeatureMonitor {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Testing utilities for feature validation
|
|
#[cfg(test)]
|
|
pub mod test_utils {
|
|
use chrono::Utc;
|
|
use common::types::{Exchange, HftTimestamp, MarketTick, TickType};
|
|
use common::{Price, Quantity, Symbol, TradeEvent};
|
|
use rust_decimal_macros::dec;
|
|
use super::unified_extractor::{
|
|
BenzingaNewsData, DatabentoBuData, NewsArticle, SentimentScore,
|
|
};
|
|
|
|
/// Create mock Databento data for testing
|
|
pub fn create_mock_databento_data() -> DatabentoBuData {
|
|
let bid_price = Price::from_f64(100.50).unwrap_or(Price::ZERO);
|
|
let ask_price = Price::from_f64(100.51).unwrap_or(Price::ZERO);
|
|
let bid_qty = Quantity::from_f64(1000.0).unwrap_or(Quantity::ZERO);
|
|
let ask_qty = Quantity::from_f64(800.0).unwrap_or(Quantity::ZERO);
|
|
|
|
DatabentoBuData {
|
|
order_book: vec![
|
|
(bid_price, bid_qty),
|
|
(ask_price, ask_qty),
|
|
],
|
|
trades: vec![TradeEvent::new(
|
|
"AAPL".to_string(),
|
|
dec!(100.505),
|
|
dec!(100),
|
|
Utc::now(),
|
|
).with_trade_id("T123")],
|
|
quotes: vec![],
|
|
timestamp: Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// Create mock Benzinga data for testing
|
|
pub fn create_mock_benzinga_data() -> BenzingaNewsData {
|
|
BenzingaNewsData {
|
|
articles: vec![NewsArticle {
|
|
title: "Apple Reports Strong Q4 Earnings".to_string(),
|
|
content: "Apple exceeded expectations...".to_string(),
|
|
source: "Reuters".to_string(),
|
|
timestamp: Utc::now(),
|
|
symbols: vec![Symbol::new("AAPL".to_string())],
|
|
category: "earnings".to_string(),
|
|
importance: 0.8,
|
|
}],
|
|
sentiment_scores: vec![SentimentScore {
|
|
symbol: Symbol::new("AAPL".to_string()),
|
|
score: 0.6,
|
|
confidence: 0.9,
|
|
timestamp: Utc::now(),
|
|
}],
|
|
analyst_ratings: vec![],
|
|
unusual_options: vec![],
|
|
timestamp: Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// Create mock historical market data
|
|
#[allow(clippy::unwrap_used)]
|
|
pub fn create_mock_historical_data() -> Vec<MarketTick> {
|
|
let base_price = 100.0_f64;
|
|
let mut data = Vec::new();
|
|
|
|
for i in 0..1000_u64 {
|
|
let price_change = (i as f64 / 100.0).sin() * 0.01;
|
|
let price = Price::from_f64(base_price + price_change).unwrap_or(Price::ZERO);
|
|
let size = Quantity::from_f64(1000.0 + (i % 500) as f64).unwrap_or(Quantity::ZERO);
|
|
|
|
data.push(MarketTick {
|
|
symbol: Symbol::new("AAPL".to_string()),
|
|
price,
|
|
size,
|
|
timestamp: HftTimestamp::now().unwrap(),
|
|
tick_type: TickType::Trade,
|
|
exchange: Exchange::NASDAQ,
|
|
sequence_number: i,
|
|
});
|
|
}
|
|
|
|
data
|
|
}
|
|
}
|