Files
foxhunt/ml/src/data_loaders/dbn_sequence_loader.rs
jgrusewski 1934367bfa refactor(ml): consolidate 13 duplicate OHLCVBar definitions into single canonical type
Created ml/src/types/ohlcv.rs as the single source of truth for OHLCVBar
(DateTime<Utc> timestamp, f64 OHLCV fields). Replaced all 13 duplicate
definitions across features/, regime/, real_data_loader, and evaluation/
with imports from crate::types::OHLCVBar.

Key changes:
- New: ml/src/types/mod.rs + ohlcv.rs with canonical OHLCVBar
  (derives: Debug, Clone, Copy, PartialEq, Serialize, Deserialize + Default)
- Renamed: evaluation::metrics::OHLCVBar → OHLCVBarF32 (genuinely
  different type: f32 fields, i64 timestamp for compact backtesting)
- Eliminated all import aliases (ExtractionOHLCVBar, RegimeOHLCVBar,
  PriceOHLCVBar, VolumeOHLCVBar) in dbn_sequence_loader.rs and pipeline.rs
- Renamed regime::orchestrator::Bar → OHLCVBar (same fields, just aliased)
- Updated 39 files total (13 definitions removed, imports normalized)

1883 lib tests passing, compilation clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:14:42 +01:00

1642 lines
65 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.
//! DBN Sequence Loader for MAMBA-2 Training
//!
//! Loads real market data from Databento DBN files and creates sequences for MAMBA-2
//! state space model training. Handles OHLCV data with microstructure features.
//!
//! ## Features
//!
//! - Loads DBN files via DbnParser
//! - Creates fixed-length sequences (60-128 timesteps)
//! - Extracts price, volume, spreads, and order flow features
//! - Maintains temporal ordering for state space models
//! - Normalizes features for neural network input
//!
//! ## Usage
//!
//! ```no_run
//! use ml::data_loaders::DbnSequenceLoader;
//! use candle_core::Device;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let loader = DbnSequenceLoader::new(60, 256).await?;
//! let (train_data, val_data) = loader
//! .load_sequences("test_data/real/databento/ml_training_small", 0.9)
//! .await?;
//!
//! println!("Loaded {} training sequences", train_data.len());
//! # Ok(())
//! # }
//! ```
use anyhow::{Context, Result};
use candle_core::{DType, Device, Tensor};
use data::providers::databento::dbn_parser::{DbnParser, ProcessedMessage};
use dbn::decode::{DbnDecoder, DbnMetadata};
use rust_decimal::prelude::*;
use std::collections::HashMap;
use std::path::Path;
use tokio::fs;
use tracing::{debug, info, warn};
use crate::data_loaders::dbn_tick_adapter::Tick;
use crate::features::alternative_bars::{
DollarBarSampler, ImbalanceBarSampler, RunBarSampler, TickBarSampler,
VolumeBarSampler,
};
use crate::features::normalization::FeatureNormalizer;
use crate::types::OHLCVBar;
// Wave D regime detection feature extractors
use crate::ensemble::MarketRegime;
use crate::features::extraction::extract_ml_features;
use crate::features::regime_adaptive::RegimeAdaptiveFeatures;
use crate::features::regime_adx::RegimeADXFeatures;
use crate::features::regime_cusum::RegimeCUSUMFeatures;
use crate::features::regime_transition::RegimeTransitionFeatures;
/// Bar sampling method for alternative bar types (Wave B)
#[derive(Debug, Clone)]
pub enum BarSamplingMethod {
/// Time-based bars (default) - fixed time intervals
TimeBars,
/// Tick bars - fixed number of ticks
TickBars(usize),
/// Volume bars - fixed volume threshold
VolumeBars(f64),
/// Dollar bars - fixed dollar value threshold
DollarBars(f64),
/// Imbalance bars - fixed imbalance threshold
ImbalanceBars(f64),
/// Run bars - consecutive directional ticks
RunBars(usize),
}
/// DBN sequence loader for MAMBA-2 training
pub struct DbnSequenceLoader {
/// DBN parser for reading binary files
parser: DbnParser,
/// Target sequence length
seq_len: usize,
/// Feature dimension (d_model) - dynamically computed from feature_config
d_model: usize,
/// Device for tensor creation
device: Device,
/// Feature statistics for normalization
stats: FeatureStats,
/// Maximum sequences per symbol (prevents memory overflow)
max_sequences_per_symbol: Option<usize>,
/// Stride for sliding window (1 = every bar, 10 = every 10th bar)
stride: usize,
/// Feature configuration (Wave A/B/C)
feature_config: crate::features::config::FeatureConfig,
/// Bar sampling method (Wave B alternative bars)
bar_sampling_method: BarSamplingMethod,
/// Feature normalizer (Wave C/D normalization)
normalizer: FeatureNormalizer,
/// Wave D regime detection feature extractors (24 features: indices 201-224)
regime_cusum: RegimeCUSUMFeatures, // 10 features (201-210)
regime_adx: RegimeADXFeatures, // 5 features (211-215)
regime_transition: RegimeTransitionFeatures, // 5 features (216-220)
regime_adaptive: RegimeAdaptiveFeatures, // 4 features (221-224)
/// Current detected regime for transition tracking
current_regime: MarketRegime,
/// OHLCV bar buffer for ADX (requires historical bars with i64 timestamp)
bar_buffer_adx: Vec<OHLCVBar>,
/// OHLCV bar buffer for adaptive features (requires historical bars with DateTime timestamp)
bar_buffer_adaptive: Vec<OHLCVBar>,
}
impl std::fmt::Debug for DbnSequenceLoader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DbnSequenceLoader")
.field("seq_len", &self.seq_len)
.field("d_model", &self.d_model)
.field("max_sequences_per_symbol", &self.max_sequences_per_symbol)
.field("stride", &self.stride)
.field("stats", &self.stats)
.field("feature_config", &self.feature_config)
.finish_non_exhaustive()
}
}
/// Feature statistics for normalization
#[derive(Debug, Clone)]
struct FeatureStats {
/// Price mean
price_mean: f64,
/// Price standard deviation
price_std: f64,
/// Volume mean
volume_mean: f64,
/// Volume standard deviation
volume_std: f64,
}
impl Default for FeatureStats {
fn default() -> Self {
Self {
price_mean: 0.0,
price_std: 1.0,
volume_mean: 0.0,
volume_std: 1.0,
}
}
}
impl DbnSequenceLoader {
/// Create new DBN sequence loader with default Wave A config (26 features)
///
/// # Arguments
/// * `seq_len` - Target sequence length (60-128 recommended)
/// * `d_model` - Feature dimension for MAMBA-2 (must match feature_config.feature_count())
///
/// # Returns
/// Configured loader ready to process DBN files
///
/// # Note
/// This constructor uses Wave A config (26 features). For other configs, use `with_feature_config()`.
pub async fn new(seq_len: usize, d_model: usize) -> Result<Self> {
use std::collections::HashMap;
let feature_config = crate::features::config::FeatureConfig::wave_a();
// Validate d_model matches feature_config
if d_model != feature_config.feature_count() {
anyhow::bail!(
"d_model ({}) does not match feature_config.feature_count() ({}). Use wave_a()={}, wave_b()={}, wave_c()={}+",
d_model,
feature_config.feature_count(),
crate::features::config::FeatureConfig::wave_a().feature_count(),
crate::features::config::FeatureConfig::wave_b().feature_count(),
crate::features::config::FeatureConfig::wave_c().feature_count()
);
}
let parser =
DbnParser::new().map_err(|e| anyhow::anyhow!("Failed to create DBN parser: {}", e))?;
// Configure symbol map for 6E.FUT (Euro FX futures)
let mut symbol_map = HashMap::new();
symbol_map.insert(0, "6E.FUT".to_string());
symbol_map.insert(1, "6E.FUT".to_string());
parser.update_symbol_map(symbol_map);
// Configure price scales (4 decimal places for FX)
let mut price_scales = HashMap::new();
price_scales.insert(0, 4);
price_scales.insert(1, 4);
parser.update_price_scales(price_scales);
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// W12-15B: Increased to 10,000 sequences per symbol (10x increase with stride=10)
// For 204K bars (6E.FUT) with seq_len=60 and stride=10: ~20,400 possible sequences
// Limited to 10K for memory efficiency (~512MB per symbol)
let max_sequences_per_symbol = Some(10_000);
let stride = 10; // Sample every 10th bar for better training data coverage (W12-15B: 10x more sequences)
info!("DBN sequence loader initialized (seq_len={}, d_model={}, feature_phase={:?}, device={:?}, max_sequences={:?}, stride={})",
seq_len, d_model, feature_config.phase, device, max_sequences_per_symbol, stride);
// Initialize feature normalizer with custom window sizes for Wave D
let normalizer = FeatureNormalizer::with_config(
50, // price_window
50, // volume_window
20, // microstructure_window
30, // regime_window (Wave D)
);
// Initialize Wave D regime detection extractors
// CUSUM: target_mean=0.0 (log returns), target_std=1.0, drift=0.5, threshold=4.0
let regime_cusum = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 4.0);
// ADX: period=14 (standard)
let regime_adx = RegimeADXFeatures::new(14);
// Transition matrix: 4 regimes, alpha=0.1
let regime_transition = RegimeTransitionFeatures::new(4, 0.1);
// Adaptive features: window=20, max_position=100K, atr_period=14
let regime_adaptive = RegimeAdaptiveFeatures::new(20, 100_000.0, 14);
Ok(Self {
parser,
seq_len,
d_model,
device,
stats: FeatureStats::default(),
max_sequences_per_symbol,
stride,
feature_config,
bar_sampling_method: BarSamplingMethod::TimeBars,
normalizer,
regime_cusum,
regime_adx,
regime_transition,
regime_adaptive,
current_regime: MarketRegime::Normal,
bar_buffer_adx: Vec::with_capacity(100),
bar_buffer_adaptive: Vec::with_capacity(100),
})
}
/// Create new DBN sequence loader with custom feature configuration
///
/// # Arguments
/// * `seq_len` - Target sequence length (60-128 recommended)
/// * `feature_config` - Feature configuration (Wave A/B/C)
///
/// # Returns
/// Configured loader with specified feature configuration
pub async fn with_feature_config(
seq_len: usize,
feature_config: crate::features::config::FeatureConfig,
) -> Result<Self> {
use std::collections::HashMap;
let d_model = feature_config.feature_count();
let parser =
DbnParser::new().map_err(|e| anyhow::anyhow!("Failed to create DBN parser: {}", e))?;
// Configure symbol map for 6E.FUT (Euro FX futures)
let mut symbol_map = HashMap::new();
symbol_map.insert(0, "6E.FUT".to_string());
symbol_map.insert(1, "6E.FUT".to_string());
parser.update_symbol_map(symbol_map);
// Configure price scales (4 decimal places for FX)
let mut price_scales = HashMap::new();
price_scales.insert(0, 4);
price_scales.insert(1, 4);
parser.update_price_scales(price_scales);
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// W12-15B: Increased to 10,000 sequences per symbol (10x increase with stride=10)
// For 204K bars (6E.FUT) with seq_len=60 and stride=10: ~20,400 possible sequences
// Limited to 10K for memory efficiency (~512MB per symbol)
let max_sequences_per_symbol = Some(10_000);
let stride = 10; // Sample every 10th bar for better training data coverage (W12-15B: 10x more sequences)
info!("DBN sequence loader initialized (seq_len={}, d_model={}, feature_phase={:?}, device={:?}, max_sequences={:?}, stride={})",
seq_len, d_model, feature_config.phase, device, max_sequences_per_symbol, stride);
// Initialize feature normalizer with custom window sizes for Wave D
let normalizer = FeatureNormalizer::with_config(
50, // price_window
50, // volume_window
20, // microstructure_window
30, // regime_window (Wave D)
);
// Initialize Wave D regime detection extractors
// CUSUM: target_mean=0.0 (log returns), target_std=1.0, drift=0.5, threshold=4.0
let regime_cusum = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 4.0);
// ADX: period=14 (standard)
let regime_adx = RegimeADXFeatures::new(14);
// Transition matrix: 4 regimes, alpha=0.1
let regime_transition = RegimeTransitionFeatures::new(4, 0.1);
// Adaptive features: window=20, max_position=100K, atr_period=14
let regime_adaptive = RegimeAdaptiveFeatures::new(20, 100_000.0, 14);
Ok(Self {
parser,
seq_len,
d_model,
device,
stats: FeatureStats::default(),
max_sequences_per_symbol,
stride,
feature_config,
bar_sampling_method: BarSamplingMethod::TimeBars,
normalizer,
regime_cusum,
regime_adx,
regime_transition,
regime_adaptive,
current_regime: MarketRegime::Normal,
bar_buffer_adx: Vec::with_capacity(100),
bar_buffer_adaptive: Vec::with_capacity(100),
})
}
/// Set bar sampling method (Wave B alternative bars)
///
/// # Arguments
/// * `method` - Bar sampling method (TimeBars, TickBars, VolumeBars, etc.)
///
/// # Example
/// ```no_run
/// # use ml::data_loaders::{DbnSequenceLoader, BarSamplingMethod};
/// # async fn example() -> anyhow::Result<()> {
/// let mut loader = DbnSequenceLoader::new(60, 26).await?;
/// loader.set_bar_sampling_method(BarSamplingMethod::DollarBars(2_000_000.0));
/// # Ok(())
/// # }
/// ```
pub fn set_bar_sampling_method(&mut self, method: BarSamplingMethod) {
info!("Setting bar sampling method: {:?}", method);
self.bar_sampling_method = method;
}
/// Get current bar sampling method
pub fn bar_sampling_method(&self) -> &BarSamplingMethod {
&self.bar_sampling_method
}
/// Create new DBN sequence loader with custom limits
///
/// # Arguments
/// * `seq_len` - Target sequence length
/// * `d_model` - Feature dimension (must match wave_a()=26, wave_b()=36, wave_c()=65+)
/// * `max_sequences_per_symbol` - Maximum sequences per symbol (None = unlimited)
/// * `stride` - Stride for sliding window (1 = every bar, 10 = every 10th bar)
///
/// # Note
/// This constructor uses Wave A config (26 features). For other configs, use `with_feature_config()`.
pub async fn with_limits(
seq_len: usize,
d_model: usize,
max_sequences_per_symbol: Option<usize>,
stride: usize,
) -> Result<Self> {
let mut loader = Self::new(seq_len, d_model).await?;
loader.max_sequences_per_symbol = max_sequences_per_symbol;
loader.stride = stride.max(1); // Ensure stride >= 1
info!(
"Custom limits set: max_sequences={:?}, stride={}",
max_sequences_per_symbol, loader.stride
);
Ok(loader)
}
/// Load sequences from a directory of DBN files
///
/// # Arguments
/// * `dbn_dir` - Directory containing .dbn files
/// * `train_split` - Fraction of data for training (0.0-1.0)
///
/// # Returns
/// Tuple of (train_sequences, val_sequences) as (input, target) pairs
///
/// # Wave B Alternative Bars
/// If `bar_sampling_method` is set to anything other than `TimeBars`, this method will:
/// 1. Load DBN OHLCV bars
/// 2. Convert to tick data using DBNTickAdapter
/// 3. Apply alternative bar sampler (Tick/Volume/Dollar/Imbalance/Run)
/// 4. Create sequences from alternative bars
pub async fn load_sequences<P: AsRef<Path>>(
&mut self,
dbn_dir: P,
train_split: f64,
) -> Result<(Vec<(Tensor, Tensor)>, Vec<(Tensor, Tensor)>)> {
let path = dbn_dir.as_ref();
info!("🔄 Loading DBN sequences from: {:?}", path);
info!(" Configuration: seq_len={}, d_model={}, stride={}, max_sequences={:?}, bar_method={:?}",
self.seq_len, self.d_model, self.stride, self.max_sequences_per_symbol, self.bar_sampling_method);
// Find all .dbn files
let mut dbn_files = Vec::new();
let mut entries = fs::read_dir(path)
.await
.with_context(|| format!("Failed to read directory: {:?}", path))?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("dbn") {
dbn_files.push(path);
}
}
dbn_files.sort();
info!("📁 Found {} DBN files", dbn_files.len());
if dbn_files.is_empty() {
return Err(anyhow::anyhow!("No DBN files found in {:?}", path));
}
// Load all messages from all files and group by symbol
let mut symbol_messages: HashMap<String, Vec<ProcessedMessage>> = HashMap::new();
let total_files = dbn_files.len();
for (idx, file_path) in dbn_files.iter().enumerate() {
let progress = ((idx + 1) as f64 / total_files as f64 * 100.0) as usize;
info!(
"📖 Processing file {}/{} ({}%): {:?}",
idx + 1,
total_files,
progress,
file_path.file_name().unwrap_or_default()
);
let messages = self.load_file(file_path).await?;
info!(" Loaded {} messages", messages.len());
// Group by symbol for temporal ordering
for msg in messages {
let symbol = self.get_message_symbol(&msg);
symbol_messages.entry(symbol).or_default().push(msg);
}
// Log memory status every 50 files
if (idx + 1) % 50 == 0 {
let total_messages: usize = symbol_messages.values().map(|v| v.len()).sum();
info!(
" 💾 Memory checkpoint: {} messages across {} symbols",
total_messages,
symbol_messages.len()
);
}
}
let total_messages: usize = symbol_messages.values().map(|v| v.len()).sum();
info!(
"✅ Loaded {} messages for {} symbols",
total_messages,
symbol_messages.len()
);
// Apply alternative bar sampling if configured (Wave B)
let symbol_messages = match &self.bar_sampling_method {
BarSamplingMethod::TimeBars => {
info!("📊 Using time-based bars (default)");
symbol_messages
},
_ => {
info!(
"🔄 Applying alternative bar sampling: {:?}",
self.bar_sampling_method
);
self.apply_alternative_bar_sampling(symbol_messages).await?
},
};
// Compute feature statistics for normalization
info!("📊 Computing feature statistics...");
self.compute_stats(&symbol_messages)?;
info!(
" price_mean={:.2}, price_std={:.2}, volume_mean={:.2}, volume_std={:.2}",
self.stats.price_mean,
self.stats.price_std,
self.stats.volume_mean,
self.stats.volume_std
);
// Create sequences from each symbol's messages
info!("🔨 Creating sequences with sliding window...");
let mut all_sequences = Vec::new();
let total_symbols = symbol_messages.len();
for (sym_idx, (symbol, messages)) in symbol_messages.into_iter().enumerate() {
let progress = ((sym_idx + 1) as f64 / total_symbols as f64 * 100.0) as usize;
info!(
" Processing symbol {}/{} ({}%): {} ({} messages)",
sym_idx + 1,
total_symbols,
progress,
symbol,
messages.len()
);
if messages.len() < self.seq_len + 1 {
warn!(
" ⚠️ Skipping {}: only {} messages (need {})",
symbol,
messages.len(),
self.seq_len + 1
);
continue;
}
let sequences = self.create_sequences(&messages)?;
info!(" Created {} sequences from {}", sequences.len(), symbol);
all_sequences.extend(sequences);
info!(" Total sequences so far: {}", all_sequences.len());
// Estimate memory usage
let seq_memory_mb =
(all_sequences.len() * self.seq_len * self.d_model * 4) / (1024 * 1024);
info!(
" 💾 Estimated memory: ~{}MB for {} sequences",
seq_memory_mb,
all_sequences.len()
);
}
info!("✅ Created {} total sequences", all_sequences.len());
if all_sequences.is_empty() {
return Err(anyhow::anyhow!(
"No sequences created! Check seq_len and data size."
));
}
// Split into train/val
info!(
"✂️ Splitting data (train={:.0}%, val={:.0}%)",
train_split * 100.0,
(1.0 - train_split) * 100.0
);
let split_idx = (all_sequences.len() as f64 * train_split) as usize;
let train_data = all_sequences[..split_idx].to_vec();
let val_data = all_sequences[split_idx..].to_vec();
info!(
"✅ Split complete: {} training, {} validation",
train_data.len(),
val_data.len()
);
Ok((train_data, val_data))
}
/// Load messages from a single DBN file using official dbn crate decoder
///
/// This replaces the custom find_data_start() heuristic that only found 2 messages.
/// Now properly decodes all OHLCV bars (400-500+ records per file).
async fn load_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<ProcessedMessage>> {
use dbn::decode::DecodeRecordRef;
use std::fs::File;
use std::io::BufReader;
let path = path.as_ref();
// Open file and create official DBN decoder
let file = File::open(path).with_context(|| format!("Failed to open: {:?}", path))?;
let reader = BufReader::new(file);
let mut decoder = DbnDecoder::new(reader)
.map_err(|e| anyhow::anyhow!("Failed to create DBN decoder: {}", e))?;
// Read metadata (for symbol mapping)
let metadata = decoder.metadata();
let symbol = metadata
.symbols
.first()
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
debug!(
"DBN file metadata: dataset={:?}, schema={:?}, symbol={}",
metadata.dataset, metadata.schema, symbol
);
// Decode all records and convert to ProcessedMessage
let mut messages = Vec::new();
let mut ohlcv_count = 0;
let mut other_count = 0;
let mut idx = 0;
loop {
match decoder.decode_record_ref() {
Ok(Some(record)) => {
idx += 1;
// Convert RecordRef to RecordRefEnum for pattern matching
let record_enum = record
.as_enum()
.map_err(|e| anyhow::anyhow!("Failed to convert record to enum: {}", e))?;
match record_enum {
dbn::RecordRefEnum::Ohlcv(ohlcv) => {
ohlcv_count += 1;
// Prices are i64 scaled by 1e-9 per DBN specification
let open_f64 = ohlcv.open as f64 * 1e-9;
let high_f64 = ohlcv.high as f64 * 1e-9;
let low_f64 = ohlcv.low as f64 * 1e-9;
let close_f64 = ohlcv.close as f64 * 1e-9;
// Log first few records for validation
if ohlcv_count <= 5 {
debug!(
"Raw OHLCV #{}: open={}, high={}, low={}, close={}",
ohlcv_count, ohlcv.open, ohlcv.high, ohlcv.low, ohlcv.close
);
debug!(
"Scaled OHLCV #{}: open={:.6}, high={:.6}, low={:.6}, close={:.6}",
ohlcv_count, open_f64, high_f64, low_f64, close_f64
);
}
// Use absolute values for Price type (futures data can have negative values)
let open = common::Price::from_f64(open_f64.abs())?;
let high = common::Price::from_f64(high_f64.abs())?;
let low = common::Price::from_f64(low_f64.abs())?;
let close = common::Price::from_f64(close_f64.abs())?;
let volume = Decimal::from(ohlcv.volume);
// Create HardwareTimestamp from ts_event (nanoseconds since Unix epoch)
use trading_engine::timing::HardwareTimestamp;
let timestamp = HardwareTimestamp::from_nanos(ohlcv.hd.ts_event);
messages.push(ProcessedMessage::Ohlcv {
symbol: symbol.clone(),
open,
high,
low,
close,
volume,
timestamp,
});
},
dbn::RecordRefEnum::Trade(trade) => {
other_count += 1;
// Prices are i64 scaled by 1e-9 per DBN specification
// Use absolute value for Price type (futures data can have negative values)
let price_f64 = trade.price as f64 * 1e-9;
let price = common::Price::from_f64(price_f64.abs())?;
let size = Decimal::from(trade.size);
use trading_engine::timing::HardwareTimestamp;
let timestamp = HardwareTimestamp::from_nanos(trade.hd.ts_event);
// Determine side from trade action/flags (c_char is i8)
use common::OrderSide;
let side = if trade.side == b'B' as i8 {
OrderSide::Buy
} else if trade.side == b'A' as i8 {
OrderSide::Sell
} else {
OrderSide::Buy // Default
};
messages.push(ProcessedMessage::Trade {
symbol: symbol.clone(),
price,
size,
side,
trade_id: None,
conditions: vec![],
timestamp,
});
},
dbn::RecordRefEnum::Mbp1(mbp) => {
other_count += 1;
// Mbp1Msg has price/size/side, not separate bid/ask fields
// Prices are i64 scaled by 1e-9 per DBN specification
// Use absolute value for Price type (futures data can have negative values)
let price_f64 = mbp.price as f64 * 1e-9;
let price = common::Price::from_f64(price_f64.abs())?;
let size = Decimal::from(mbp.size);
use trading_engine::timing::HardwareTimestamp;
let timestamp = HardwareTimestamp::from_nanos(mbp.hd.ts_event);
// Determine bid/ask from side field (c_char is i8)
let (bid, ask, bid_size, ask_size) = if mbp.side == b'B' as i8 {
// Bid side
(Some(price), None, Some(size), None)
} else if mbp.side == b'A' as i8 {
// Ask side
(None, Some(price), None, Some(size))
} else {
// Unknown side - treat as bid
(Some(price), None, Some(size), None)
};
messages.push(ProcessedMessage::Quote {
symbol: symbol.clone(),
bid,
ask,
bid_size,
ask_size,
exchange: Some("UNKNOWN".to_string()),
timestamp,
});
},
_ => {
// Skip other message types
},
}
},
Ok(None) => {
// End of stream
break;
},
Err(e) => {
return Err(anyhow::anyhow!("Failed to decode record {}: {}", idx, e));
},
}
}
info!(
"Loaded {} OHLCV messages from {:?} ({} other messages)",
ohlcv_count,
path.file_name().unwrap_or_default(),
other_count
);
Ok(messages)
}
/// Apply alternative bar sampling to OHLCV messages (Wave B)
///
/// Converts time-based OHLCV bars to alternative bar types:
/// - TickBars: Fixed number of ticks
/// - VolumeBars: Fixed volume threshold
/// - DollarBars: Fixed dollar value threshold
/// - ImbalanceBars: Fixed imbalance threshold
/// - RunBars: Consecutive directional ticks
///
/// # Process
/// 1. Convert OHLCV bars to ticks (4 ticks per bar: OHLC)
/// 2. Apply alternative bar sampler
/// 3. Convert alternative bars back to ProcessedMessage::Ohlcv
async fn apply_alternative_bar_sampling(
&self,
symbol_messages: HashMap<String, Vec<ProcessedMessage>>,
) -> Result<HashMap<String, Vec<ProcessedMessage>>> {
let mut result = HashMap::new();
for (symbol, messages) in symbol_messages.into_iter() {
info!(" Processing {} messages for {}", messages.len(), symbol);
// Convert OHLCV messages to ticks
let ticks = self.ohlcv_messages_to_ticks(&messages)?;
info!(" Converted to {} ticks", ticks.len());
// Apply alternative bar sampler
let alternative_bars = self.apply_bar_sampler(&ticks)?;
info!(" Generated {} alternative bars", alternative_bars.len());
// Convert alternative bars back to ProcessedMessage::Ohlcv
let alternative_messages: Vec<ProcessedMessage> = alternative_bars
.into_iter()
.map(|bar| ProcessedMessage::Ohlcv {
symbol: symbol.clone(),
open: common::Price::from_f64(bar.open)
.unwrap_or_else(|_| common::Price::from_f64(0.0).unwrap()),
high: common::Price::from_f64(bar.high)
.unwrap_or_else(|_| common::Price::from_f64(0.0).unwrap()),
low: common::Price::from_f64(bar.low)
.unwrap_or_else(|_| common::Price::from_f64(0.0).unwrap()),
close: common::Price::from_f64(bar.close)
.unwrap_or_else(|_| common::Price::from_f64(0.0).unwrap()),
volume: Decimal::from_f64(bar.volume).unwrap_or(Decimal::ZERO),
timestamp: trading_engine::timing::HardwareTimestamp::from_nanos(
bar.timestamp.timestamp_nanos_opt().unwrap_or(0) as u64,
),
})
.collect();
result.insert(symbol, alternative_messages);
}
Ok(result)
}
/// Convert OHLCV messages to ticks (4 ticks per bar: open, high, low, close)
fn ohlcv_messages_to_ticks(&self, messages: &[ProcessedMessage]) -> Result<Vec<Tick>> {
let mut ticks = Vec::new();
for msg in messages {
match msg {
ProcessedMessage::Ohlcv {
open,
high,
low,
close,
volume,
timestamp,
..
} => {
// Convert timestamp to DateTime<Utc>
let ts_nanos = timestamp.as_nanos() as i64;
let datetime = chrono::DateTime::from_timestamp_nanos(ts_nanos);
// Distribute volume evenly across 4 ticks
let volume_per_tick = volume.to_f64().unwrap_or(0.0) / 4.0;
// Create 4 ticks per bar (OHLC)
ticks.push(Tick {
price: open.to_f64(),
volume: volume_per_tick,
timestamp: datetime,
});
ticks.push(Tick {
price: high.to_f64(),
volume: volume_per_tick,
timestamp: datetime,
});
ticks.push(Tick {
price: low.to_f64(),
volume: volume_per_tick,
timestamp: datetime,
});
ticks.push(Tick {
price: close.to_f64(),
volume: volume_per_tick,
timestamp: datetime,
});
},
_ => {
// Skip non-OHLCV messages
},
}
}
Ok(ticks)
}
/// Apply bar sampler based on configured method
fn apply_bar_sampler(&self, ticks: &[Tick]) -> Result<Vec<OHLCVBar>> {
let mut bars = Vec::new();
match &self.bar_sampling_method {
BarSamplingMethod::TimeBars => {
// This should never happen (already handled in load_sequences)
anyhow::bail!("TimeBars should not reach apply_bar_sampler");
},
BarSamplingMethod::TickBars(threshold) => {
let mut sampler = TickBarSampler::new(*threshold);
for tick in ticks {
if let Some(bar) = sampler.update(tick.price, tick.volume, tick.timestamp) {
bars.push(bar);
}
}
},
BarSamplingMethod::VolumeBars(threshold) => {
let mut sampler = VolumeBarSampler::new(*threshold as u64);
for tick in ticks {
if let Some(bar) = sampler.update(tick.price, tick.volume, tick.timestamp) {
bars.push(bar);
}
}
},
BarSamplingMethod::DollarBars(threshold) => {
let mut sampler = DollarBarSampler::new(*threshold);
for tick in ticks {
if let Some(bar) = sampler.update(tick.price, tick.volume, tick.timestamp) {
bars.push(bar);
}
}
},
BarSamplingMethod::ImbalanceBars(threshold) => {
if let Some(first_tick) = ticks.first() {
// Initialize with first tick's price and timestamp
let mut sampler = ImbalanceBarSampler::new(
first_tick.price,
*threshold,
first_tick.timestamp,
);
for tick in ticks {
if let Some(bar) = sampler.update(tick.price, tick.volume, tick.timestamp) {
bars.push(bar);
}
}
}
},
BarSamplingMethod::RunBars(threshold) => {
let mut sampler = RunBarSampler::new(*threshold);
for tick in ticks {
if let Some(bar) = sampler.update(tick.price, tick.volume, tick.timestamp) {
bars.push(bar);
}
}
},
}
Ok(bars)
}
/// Get symbol from processed message
fn get_message_symbol(&self, msg: &ProcessedMessage) -> String {
match msg {
ProcessedMessage::Trade { symbol, .. } => symbol.clone(),
ProcessedMessage::Quote { symbol, .. } => symbol.clone(),
ProcessedMessage::OrderBook { symbol, .. } => symbol.clone(),
ProcessedMessage::Ohlcv { symbol, .. } => symbol.clone(),
ProcessedMessage::Status { .. } => "UNKNOWN".to_string(),
}
}
/// Compute feature statistics for normalization
fn compute_stats(
&mut self,
symbol_messages: &HashMap<String, Vec<ProcessedMessage>>,
) -> Result<()> {
let mut prices = Vec::new();
let mut volumes = Vec::new();
for messages in symbol_messages.values() {
for msg in messages {
match msg {
ProcessedMessage::Ohlcv { close, volume, .. } => {
prices.push(close.to_f64());
volumes.push(volume.to_f64().unwrap_or(0.0));
},
ProcessedMessage::Trade { price, size, .. } => {
prices.push(price.to_f64());
volumes.push(size.to_f64().unwrap_or(0.0));
},
_ => {},
}
}
}
if prices.is_empty() {
return Err(anyhow::anyhow!("No price data found for normalization"));
}
// Compute mean and std
let price_mean = prices.iter().sum::<f64>() / prices.len() as f64;
let price_var =
prices.iter().map(|p| (p - price_mean).powi(2)).sum::<f64>() / prices.len() as f64;
let price_std = price_var.sqrt().max(1e-8);
let volume_mean = volumes.iter().sum::<f64>() / volumes.len() as f64;
let volume_var = volumes
.iter()
.map(|v| (v - volume_mean).powi(2))
.sum::<f64>()
/ volumes.len() as f64;
let volume_std = volume_var.sqrt().max(1e-8);
self.stats = FeatureStats {
price_mean,
price_std,
volume_mean,
volume_std,
};
Ok(())
}
/// Create sequences from message list
///
/// Uses sliding window with stride and optional max limit to prevent memory overflow.
/// For 665K bars: stride=10, max=10K → 10K sequences instead of 665K
fn create_sequences(&mut self, messages: &[ProcessedMessage]) -> Result<Vec<(Tensor, Tensor)>> {
let mut sequences = Vec::new();
// REFACTOR (Wave 5 Agent 26): Use production extract_ml_features() pipeline
// This eliminates 43 zero-padded features (19.1% junk data)
// Step 1: Convert ProcessedMessage to OHLCVBar format for production pipeline
let bars: Vec<OHLCVBar> = messages
.iter()
.filter_map(|msg| self.convert_message_to_bar(msg))
.collect();
if bars.is_empty() {
return Err(anyhow::anyhow!("No OHLCV bars found in messages"));
}
// Step 2: Extract all features using production pipeline (requires 50-bar warmup)
const WARMUP_PERIOD: usize = 50;
if bars.len() < WARMUP_PERIOD + self.seq_len + 1 {
return Err(anyhow::anyhow!(
"Insufficient bars: {} available, need {} (warmup) + {} (seq_len) + 1 (target)",
bars.len(),
WARMUP_PERIOD,
self.seq_len
));
}
debug!(
"Extracting features using production pipeline: {} bars → 54 features per bar",
bars.len()
);
// extract_ml_features() returns Vec<[f64; 54]> after warmup period
let feature_vectors = extract_ml_features(&bars)
.context("Failed to extract 54-feature vectors from production pipeline")?;
debug!(
"✓ Extracted {} feature vectors (54 dims each) after warmup",
feature_vectors.len()
);
// Step 3: Create sequences from feature vectors
// Calculate maximum possible sequences
let max_possible = feature_vectors.len().saturating_sub(self.seq_len);
// Apply stride (e.g., every 10th bar)
let num_sequences_with_stride = (max_possible + self.stride - 1) / self.stride;
// Apply max limit if specified
let target_num_sequences = match self.max_sequences_per_symbol {
Some(max) => num_sequences_with_stride.min(max),
None => num_sequences_with_stride,
};
debug!(
"Sequence generation: {} feature vectors → {} sequences (stride={}, max={:?})",
feature_vectors.len(),
target_num_sequences,
self.stride,
self.max_sequences_per_symbol
);
// Pre-allocate to prevent reallocation during loop
sequences.reserve(target_num_sequences);
// Progress tracking for large datasets
let progress_interval = (target_num_sequences / 10).max(1); // Log every 10%
// Sliding window over feature vectors with stride
let mut seq_count = 0;
let mut i = 0;
while i < max_possible && seq_count < target_num_sequences {
// Log progress every 10%
if seq_count > 0 && seq_count % progress_interval == 0 {
let progress = (seq_count as f64 / target_num_sequences as f64 * 100.0) as usize;
debug!(
" Sequence generation: {}% ({}/{})",
progress, seq_count, target_num_sequences
);
}
// Extract seq_len feature vectors for input
let mut features = Vec::with_capacity(self.seq_len * 54);
for j in 0..self.seq_len {
let feature_vec = &feature_vectors[i + j];
// Convert [f64; 54] to f32 and apply normalization
let mut features_f32: Vec<f32> = feature_vec.iter().map(|&x| x as f32).collect();
features_f32 = self.normalize_features(&features_f32)?;
features.extend_from_slice(&features_f32);
}
// FIXED (Agent 254): Target is next close price (regression), not full feature vector
// Extract target price from the bar that corresponds to feature_vectors[i + seq_len]
// Note: bars[warmup + i + seq_len] is the actual bar for feature_vectors[i + seq_len]
let target_bar_idx = WARMUP_PERIOD + i + self.seq_len;
let target_price = bars[target_bar_idx].close;
// Normalize target price using same stats as features
let normalized_target =
((target_price - self.stats.price_mean) / self.stats.price_std) as f32;
// Create tensors with batch dimension
// Input: [batch=1, seq_len, d_model] = [1, 60, 54]
// Target: [batch=1, 1, 1] = single price for regression
let input = Tensor::from_slice(&features, (1, self.seq_len, 54), &self.device)?
.to_dtype(DType::F64)?;
let target_tensor = Tensor::from_slice(&[normalized_target], (1, 1, 1), &self.device)?
.to_dtype(DType::F64)?;
sequences.push((input, target_tensor));
seq_count += 1;
i += self.stride; // Apply stride (skip bars)
}
debug!(
"✓ Generated {} sequences from {} feature vectors (0% zero-padding)",
sequences.len(),
feature_vectors.len()
);
Ok(sequences)
}
/// Convert ProcessedMessage to OHLCVBar format for production feature extraction
///
/// REFACTOR (Wave 5 Agent 26): Helper method to bridge DbnSequenceLoader with production pipeline
fn convert_message_to_bar(&self, msg: &ProcessedMessage) -> Option<OHLCVBar> {
match msg {
ProcessedMessage::Ohlcv {
timestamp,
open,
high,
low,
close,
volume,
..
} => {
// Convert HardwareTimestamp (nanos) to chrono::DateTime<Utc>
let nanos = timestamp.nanos;
let secs = (nanos / 1_000_000_000) as i64;
let nsec = (nanos % 1_000_000_000) as u32;
let timestamp_dt =
chrono::DateTime::from_timestamp(secs, nsec).unwrap_or_else(chrono::Utc::now);
Some(OHLCVBar {
timestamp: timestamp_dt,
open: open.to_f64(),
high: high.to_f64(),
low: low.to_f64(),
close: close.to_f64(),
volume: volume.to_f64().unwrap_or(0.0),
})
},
// Only OHLCV messages are supported for production feature extraction
// Trade and Quote messages are not bar-based
_ => None,
}
}
/// Extract target price (close price) for regression
///
/// FIXED (Agent 254): Model output_dim=1 for price prediction (regression)
/// Target should be single close price, not full 256-dim feature vector
fn extract_target_price(&self, msg: &ProcessedMessage) -> Result<f32> {
match msg {
ProcessedMessage::Ohlcv { close, .. } => {
// Normalize close price using same stats as features
let c = (close.to_f64() - self.stats.price_mean) / self.stats.price_std;
Ok(c as f32)
},
ProcessedMessage::Trade { price, .. } => {
// For trades, use price as target
let p = (price.to_f64() - self.stats.price_mean) / self.stats.price_std;
Ok(p as f32)
},
ProcessedMessage::Quote { ask, bid, .. } => {
// For quotes, use mid-price as target
let mid = match (ask, bid) {
(Some(a), Some(b)) => (a.to_f64() + b.to_f64()) / 2.0,
(Some(a), None) => a.to_f64(),
(None, Some(b)) => b.to_f64(),
_ => 0.0,
};
let normalized = (mid - self.stats.price_mean) / self.stats.price_std;
Ok(normalized as f32)
},
_ => {
// Default: return 0.0 for other message types
Ok(0.0)
},
}
}
/// Extract normalized features from a message (dynamic based on FeatureConfig)
///
/// FIXED (Agent C2): Removed 54-feature padding bug. Now extracts real features
/// based on FeatureConfig (Wave A: 26, Wave B: 36, Wave C: 65+).
///
/// Wave A features (26):
/// LEGACY METHOD (Wave 5 Agent 26): This method is DEPRECATED and should NOT be used.
///
/// Use production `extract_ml_features()` pipeline instead (0% zero-padding).
///
/// This method produced 43 zero-padded features (19.1% junk data):
/// - Alternative bar features (10): lines 1221-1227
/// - Microstructure features (3): lines 1230-1236
/// - Fractional differentiation (20): lines 1239-1244
/// - Regime detection (10): lines 1247-1252
///
/// ## Original Feature Breakdown (Wave A only - 26 features):
/// - Base OHLCV (5 features): open, high, low, close, volume
/// - Derived features (4 features): range, body, upper_wick, lower_wick
/// - Price ratios (10 features): c/o, h/l, h/c, l/c, c/h, c/l, body/range, upper_wick/range, lower_wick/range, v/price
/// - Log returns (4 features): ln(c/o), ln(h/o), ln(l/o), ln(c/h)
/// - Price deltas (3 features): c-o, h-o, l-o (removed c-l to match 26 total)
///
/// Total: 5 + 4 + 10 + 4 + 3 = 26 features (matches FeatureConfig::wave_a())
#[allow(dead_code)]
fn extract_features(&mut self, msg: &ProcessedMessage) -> Result<Vec<f32>> {
match msg {
ProcessedMessage::Ohlcv {
open,
high,
low,
close,
volume,
..
} => {
// Normalize OHLCV
let o = (open.to_f64() - self.stats.price_mean) / self.stats.price_std;
let h = (high.to_f64() - self.stats.price_mean) / self.stats.price_std;
let l = (low.to_f64() - self.stats.price_mean) / self.stats.price_std;
let c = (close.to_f64() - self.stats.price_mean) / self.stats.price_std;
let v = (volume.to_f64().unwrap_or(0.0) - self.stats.volume_mean)
/ self.stats.volume_std;
// Derived features
let range = h - l; // High-Low range
let body = c - o; // Close-Open (candle body)
let upper_wick = h - c.max(o); // Upper shadow
let lower_wick = l.min(o) - l; // Lower shadow
// Build feature vector based on FeatureConfig
let mut features = Vec::with_capacity(self.d_model);
// 1. Base OHLCV (5 features) - always included in Wave A/B/C
if self.feature_config.enable_ohlcv {
features.push(o as f32);
features.push(h as f32);
features.push(l as f32);
features.push(c as f32);
features.push(v as f32);
}
// 2. Derived features (4 features) - part of Wave A technical indicators
if self.feature_config.enable_technical_indicators {
features.push(range as f32);
features.push(body as f32);
features.push(upper_wick as f32);
features.push(lower_wick as f32);
}
// 3. Price ratios (10 features) - part of Wave A technical indicators
if self.feature_config.enable_technical_indicators {
let safe_div =
|a: f64, b: f64| if b.abs() > 1e-8 { (a / b) as f32 } else { 0.0 };
features.push(safe_div(c, o)); // close/open ratio
features.push(safe_div(h, l)); // high/low ratio
features.push(safe_div(h, c)); // high/close ratio
features.push(safe_div(l, c)); // low/close ratio
features.push(safe_div(c, h)); // close/high ratio (upper position)
features.push(safe_div(c, l)); // close/low ratio (lower position)
features.push(safe_div(body.abs(), range.max(1e-8))); // body/range ratio
features.push(safe_div(upper_wick, range.max(1e-8))); // upper wick ratio
features.push(safe_div(lower_wick, range.max(1e-8))); // lower wick ratio
features.push(safe_div(v, (h + l + c + o) / 4.0)); // volume/price ratio
}
// 4. Log returns (4 features) - part of Wave A technical indicators
if self.feature_config.enable_technical_indicators {
let safe_ln = |a: f64, b: f64| {
let ratio = a / b.max(1e-8);
if ratio > 0.0 {
ratio.ln() as f32
} else {
0.0 // Return 0 for negative or zero ratios (normalized prices can be negative)
}
};
features.push(safe_ln(c, o)); // log return
features.push(safe_ln(h, o)); // log high return
features.push(safe_ln(l, o)); // log low return
features.push(safe_ln(c, h)); // log close/high
}
// 5. Price deltas (3 features) - part of Wave A technical indicators
// REMOVED: (c - l) to match 26-feature count
if self.feature_config.enable_technical_indicators {
features.push((c - o) as f32); // raw price change
features.push((h - o) as f32); // open to high
features.push((l - o) as f32); // open to low
}
// 6. Alternative bar features (10 features) - Wave B
if self.feature_config.enable_alternative_bars {
// TODO (Wave B): Add dollar bar, volume bar, tick bar, run bar, imbalance bar features
// For now, pad with zeros
for _ in 0..10 {
features.push(0.0);
}
}
// 7. Microstructure features (3 features) - Wave A/C
if self.feature_config.enable_microstructure {
// TODO: Add Amihud Illiquidity, Roll Measure, Corwin-Schultz Spread
// For now, pad with zeros (not yet integrated)
for _ in 0..3 {
features.push(0.0);
}
}
// 8. Fractional differentiation features (20 features) - Wave C
if self.feature_config.enable_fractional_diff {
// TODO (Wave C): Add fractional differentiation features
for _ in 0..20 {
features.push(0.0);
}
}
// 9. Regime detection features (10 features) - Wave C
if self.feature_config.enable_regime_detection {
// TODO (Wave C): Add CUSUM structural breaks, regime indicators
for _ in 0..10 {
features.push(0.0);
}
}
// 10. Wave D regime features (24 features) - Wave D
if self.feature_config.enable_wave_d_regime {
// CUSUM Statistics (indices 201-210, 10 features)
// Use log return as input to CUSUM detector
let log_return = if o.abs() > 1e-8 { (c / o).ln() } else { 0.0 };
let cusum_features = self.regime_cusum.update(log_return);
for &feat in &cusum_features {
features.push(feat as f32);
}
// ADX & Directional Indicators (indices 211-215, 5 features)
// Create OHLCVBar for ADX calculation
let current_bar_adx = OHLCVBar {
timestamp: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap(), // Not used in feature calculation
open: open.to_f64(),
high: high.to_f64(),
low: low.to_f64(),
close: close.to_f64(),
volume: volume.to_f64().unwrap_or(0.0),
};
// Add current bar to ADX buffer
self.bar_buffer_adx.push(current_bar_adx.clone());
if self.bar_buffer_adx.len() > 100 {
self.bar_buffer_adx.remove(0); // Keep last 100 bars
}
let adx_features = self.regime_adx.update(&current_bar_adx);
for &feat in &adx_features {
features.push(feat as f32);
}
// Regime Transition Probabilities (indices 216-220, 5 features)
// Update transition matrix with current regime and compute features
let transition_features = self.regime_transition.update(self.current_regime);
for &feat in &transition_features {
features.push(feat as f32);
}
// Adaptive Strategy Metrics (indices 221-224, 4 features)
// Create OHLCVBar for adaptive features (requires DateTime timestamp)
let current_bar_adaptive = OHLCVBar {
timestamp: chrono::Utc::now(), // Use current time
open: open.to_f64(),
high: high.to_f64(),
low: low.to_f64(),
close: close.to_f64(),
volume: volume.to_f64().unwrap_or(0.0),
};
// Add current bar to adaptive buffer
self.bar_buffer_adaptive.push(current_bar_adaptive);
if self.bar_buffer_adaptive.len() > 100 {
self.bar_buffer_adaptive.remove(0); // Keep last 100 bars
}
// Use recent returns and current position size
let adaptive_features = self.regime_adaptive.update(
self.current_regime,
log_return,
50_000.0, // Current position size (50% of max)
&self.bar_buffer_adaptive,
);
for &feat in &adaptive_features {
features.push(feat as f32);
}
}
// Sanity check: ensure feature count matches FeatureConfig
debug_assert_eq!(
features.len(),
self.feature_config.feature_count(),
"Feature vector must match FeatureConfig.feature_count(): expected {}, got {}",
self.feature_config.feature_count(),
features.len()
);
Ok(features)
},
ProcessedMessage::Trade { price, size, .. } => {
// Trade messages: create feature vector based on FeatureConfig
let p = (price.to_f64() - self.stats.price_mean) / self.stats.price_std;
let s =
(size.to_f64().unwrap_or(0.0) - self.stats.volume_mean) / self.stats.volume_std;
// Use price as OHLCV (all same for trades)
let mut features = Vec::with_capacity(self.d_model);
if self.feature_config.enable_ohlcv {
features.push(p as f32); // open
features.push(p as f32); // high
features.push(p as f32); // low
features.push(p as f32); // close
features.push(s as f32); // volume
}
// Pad remaining features with zeros
while features.len() < self.feature_config.feature_count() {
features.push(0.0);
}
Ok(features)
},
ProcessedMessage::Quote { bid, ask, .. } => {
// Quote messages: create feature vector based on FeatureConfig
let b = bid
.map(|p| (p.to_f64() - self.stats.price_mean) / self.stats.price_std)
.unwrap_or(0.0);
let a = ask
.map(|p| (p.to_f64() - self.stats.price_mean) / self.stats.price_std)
.unwrap_or(0.0);
let spread = a - b;
let mid = (a + b) / 2.0;
// Use mid-price as OHLCV (all same for quotes)
let mut features = Vec::with_capacity(self.d_model);
if self.feature_config.enable_ohlcv {
features.push(mid as f32); // open
features.push(a as f32); // high (ask)
features.push(b as f32); // low (bid)
features.push(mid as f32); // close
features.push(spread as f32); // volume (use spread)
}
// Pad remaining features with zeros
while features.len() < self.feature_config.feature_count() {
features.push(0.0);
}
Ok(features)
},
_ => {
// Default fallback: zero vector of d_model dimensions
Ok(vec![0.0; self.feature_config.feature_count()])
},
}
}
/// Normalize features using FeatureNormalizer (Agent F1: Critical for numerical stability)
///
/// Converts f32 features to f64, applies normalization, and converts back to f32.
/// This prevents numerical instability in MAMBA-2 training (loss values at 10^38 scale).
///
/// # Arguments
/// * `features` - Raw features (26/36/65/54 dimensions)
///
/// # Returns
/// Normalized features with all values in reasonable ranges
fn normalize_features(&self, features: &[f32]) -> Result<Vec<f32>> {
// Convert f32 -> f64 (FeatureNormalizer uses f64)
let mut feature_vec_f64: [f64; 51] = [0.0; 51];
for (i, &val) in features.iter().enumerate() {
if i < 51 {
feature_vec_f64[i] = val as f64;
}
}
// Apply normalization (requires mutable borrow)
// We'll use interior mutability pattern with Cell/RefCell in production
// For now, we'll compute normalized features without state update
// (warmup phase will be skipped, but normalization logic still applies)
// Instead, let's compute normalization manually based on feature indices
// This avoids the mutable borrow issue while still providing normalization
self.apply_manual_normalization(&mut feature_vec_f64)?;
// Convert back to f32
let mut normalized_f32 = Vec::with_capacity(features.len());
for i in 0..features.len() {
normalized_f32.push(feature_vec_f64[i] as f32);
}
Ok(normalized_f32)
}
/// Apply manual normalization based on feature indices (Agent F1)
///
/// This is a stateless normalization that applies scaling without requiring
/// rolling window state updates. Uses fixed scaling factors appropriate for
/// each feature category.
fn apply_manual_normalization(&self, features: &mut [f64; 51]) -> Result<()> {
// Skip OHLCV (indices 0-4): already normalized by extract_features()
// Skip technical indicators (indices 5-14): already in normalized ranges
// Normalize price features (indices 15-74): z-score with clipping
for i in 15..75 {
if i < features.len() {
// Clip to ±3σ range
features[i] = features[i].clamp(-3.0, 3.0);
}
}
// Normalize volume features (indices 75-114): percentile rank [0, 1]
for i in 75..115 {
if i < features.len() {
// Clip to [0, 1] range
features[i] = features[i].clamp(0.0, 1.0);
}
}
// Normalize microstructure features (indices 115-164): log+z-score
for i in 115..165 {
if i < features.len() {
// Clip to ±3σ range
features[i] = features[i].clamp(-3.0, 3.0);
}
}
// Skip time/statistical features (indices 165-200): already normalized
// Normalize Wave D CUSUM features (indices 201-210): z-score
for i in 201..211 {
if i < features.len() {
features[i] = features[i].clamp(-3.0, 3.0);
}
}
// Normalize Wave D ADX features (indices 211-215): [0, 1]
for i in 211..216 {
if i < features.len() {
features[i] = features[i].clamp(0.0, 1.0);
}
}
// Normalize Wave D transition features (indices 216-220): z-score
for i in 216..221 {
if i < features.len() {
features[i] = features[i].clamp(-3.0, 3.0);
}
}
// Normalize Wave D adaptive features (indices 221-224): [0, 2]
for i in 50..54 {
if i < features.len() {
features[i] = features[i].clamp(0.0, 2.0);
}
}
// Final validation: ensure all features are finite
for (i, &val) in features.iter().enumerate() {
if !val.is_finite() {
anyhow::bail!("Feature {} is non-finite after normalization: {}", i, val);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_loader_creation_wave_a() {
// Wave A: 26 features
let loader = DbnSequenceLoader::new(60, 26).await;
assert!(loader.is_ok());
let loader = loader.unwrap();
assert_eq!(loader.d_model, 26);
assert_eq!(loader.feature_config.feature_count(), 26);
}
#[tokio::test]
async fn test_loader_with_feature_config_wave_b() {
// Wave B: 36 features
let config = crate::features::config::FeatureConfig::wave_b();
let loader = DbnSequenceLoader::with_feature_config(60, config).await;
assert!(loader.is_ok());
let loader = loader.unwrap();
assert_eq!(loader.d_model, 36);
assert_eq!(loader.feature_config.feature_count(), 36);
}
#[tokio::test]
async fn test_loader_with_feature_config_wave_c() {
// Wave C: 65+ features
let config = crate::features::config::FeatureConfig::wave_c();
let loader = DbnSequenceLoader::with_feature_config(60, config).await;
assert!(loader.is_ok());
let loader = loader.unwrap();
assert!(loader.d_model >= 65);
assert_eq!(loader.feature_config.feature_count(), loader.d_model);
}
#[tokio::test]
async fn test_loader_rejects_mismatched_d_model() {
// Should fail: d_model=256 does not match Wave A (26 features)
let loader = DbnSequenceLoader::new(60, 256).await;
assert!(loader.is_err());
let err = loader.unwrap_err();
assert!(err.to_string().contains("does not match"));
}
#[test]
fn test_feature_stats_default() {
let stats = FeatureStats::default();
assert_eq!(stats.price_mean, 0.0);
assert_eq!(stats.price_std, 1.0);
}
}