//! Alternative Bar Sampling Techniques //! //! Implementation of alternative bar types for improved ML model performance: //! - Tick Bars: Aggregate every N ticks (Agent B3 - PRIMARY TASK) //! - Volume Bars: Aggregate every N volume units //! - Dollar Bars: Aggregate every $N traded //! - Run Bars: Aggregate based on consecutive directional ticks //! - Imbalance Bars: Aggregate based on buy/sell imbalance //! //! Based on Lopez de Prado (2018) - "Advances in Financial Machine Learning" use chrono::{DateTime, Utc}; pub use crate::OHLCVBar; /// Tick Bar Sampler - Aggregates every N ticks (PRIMARY IMPLEMENTATION - Agent B3) /// /// Performance: <50μs per bar (target from Wave B Agent B3) /// /// # Example /// ``` /// use ml::features::alternative_bars::TickBarSampler; /// use chrono::Utc; /// /// let mut sampler = TickBarSampler::new(100); // 100 ticks per bar /// /// for i in 0..150 { /// let price = 100.0 + (i as f64 * 0.01); /// let volume = 10.0; /// let timestamp = Utc::now(); /// /// if let Some(bar) = sampler.update(price, volume, timestamp) { /// println!("Bar formed: O={} H={} L={} C={} V={}", /// bar.open, bar.high, bar.low, bar.close, bar.volume); /// } /// } /// ``` #[derive(Debug)] pub struct TickBarSampler { /// Number of ticks required to form a bar threshold: usize, /// Current tick count in the active bar tick_count: usize, /// Timestamp of the first tick in the current bar first_timestamp: Option>, /// Opening price of the current bar current_open: Option, /// Highest price seen in the current bar current_high: f64, /// Lowest price seen in the current bar current_low: f64, /// Cumulative volume in the current bar cumulative_volume: f64, /// Last price (becomes close when bar completes) last_price: f64, } impl TickBarSampler { /// Create a new tick bar sampler /// /// # Arguments /// * `threshold` - Number of ticks per bar (e.g., 100, 1000) /// /// # Panics /// Panics if threshold is 0 pub fn new(threshold: usize) -> Self { assert!(threshold > 0, "Threshold must be greater than 0"); Self { threshold, tick_count: 0, first_timestamp: None, current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, cumulative_volume: 0.0, last_price: 0.0, } } /// Process a single tick and return completed bar if threshold reached /// /// # Arguments /// * `price` - Trade price /// * `volume` - Trade volume (can be 0) /// * `timestamp` - Trade timestamp /// /// # Returns /// `Some(OHLCVBar)` if a bar was completed, `None` otherwise pub fn update( &mut self, price: f64, volume: f64, timestamp: DateTime, ) -> Option { // Initialize on first tick if self.current_open.is_none() { self.current_open = Some(price); self.first_timestamp = Some(timestamp); } // Update OHLCV self.current_high = self.current_high.max(price); self.current_low = self.current_low.min(price); self.cumulative_volume += volume; self.last_price = price; // Increment tick count self.tick_count += 1; // Check if bar is complete (self.tick_count >= self.threshold).then(|| { let bar = OHLCVBar { timestamp: self.first_timestamp.unwrap_or(timestamp), open: self.current_open.unwrap_or(price), high: self.current_high, low: self.current_low, close: self.last_price, volume: self.cumulative_volume, }; // Reset for next bar self.reset(); bar }) } /// Get the tick threshold pub const fn threshold(&self) -> usize { self.threshold } /// Get the current tick count (0 to threshold-1) pub const fn tick_count(&self) -> usize { self.tick_count } /// Reset the sampler state for a new bar const fn reset(&mut self) { self.tick_count = 0; self.first_timestamp = None; self.current_open = None; self.current_high = f64::NEG_INFINITY; self.current_low = f64::INFINITY; self.cumulative_volume = 0.0; } } // Additional samplers for future agents /// Volume Bar Sampler - Aggregates every N volume units #[derive(Debug)] pub struct VolumeBarSampler { threshold: u64, cumulative_volume: u64, first_timestamp: Option>, current_open: Option, current_high: f64, current_low: f64, last_price: f64, } impl VolumeBarSampler { pub fn new(threshold: u64) -> Self { assert!(threshold > 0, "Threshold must be greater than 0"); Self { threshold, cumulative_volume: 0, first_timestamp: None, current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, last_price: 0.0, } } pub fn update( &mut self, price: f64, volume: f64, timestamp: DateTime, ) -> Option { let volume_units = volume.round() as u64; self.cumulative_volume += volume_units; if self.current_open.is_none() { self.current_open = Some(price); self.first_timestamp = Some(timestamp); } self.current_high = self.current_high.max(price); self.current_low = self.current_low.min(price); self.last_price = price; (self.cumulative_volume >= self.threshold).then(|| { let bar = OHLCVBar { timestamp: self.first_timestamp.unwrap_or(timestamp), open: self.current_open.unwrap_or(price), high: self.current_high, low: self.current_low, close: self.last_price, volume: self.cumulative_volume as f64, }; self.reset(); bar }) } pub const fn threshold(&self) -> u64 { self.threshold } pub const fn cumulative_volume(&self) -> u64 { self.cumulative_volume } const fn reset(&mut self) { self.cumulative_volume = 0; self.first_timestamp = None; self.current_open = None; self.current_high = f64::NEG_INFINITY; self.current_low = f64::INFINITY; } } /// Dollar Bar Sampler - Aggregates every $N traded #[derive(Debug)] pub struct DollarBarSampler { threshold: f64, cumulative_dollar: f64, first_timestamp: Option>, current_open: Option, current_high: f64, current_low: f64, cumulative_volume: f64, last_price: f64, adaptive_mode: bool, ewma_alpha: f64, } impl DollarBarSampler { pub fn new(threshold: f64) -> Self { assert!(threshold > 0.0, "Threshold must be greater than 0"); Self { threshold, cumulative_dollar: 0.0, first_timestamp: None, current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, cumulative_volume: 0.0, last_price: 0.0, adaptive_mode: false, ewma_alpha: 0.0, } } /// Create adaptive dollar bar sampler with EWMA threshold adjustment pub fn new_adaptive(initial_threshold: f64, alpha: f64) -> Self { assert!( initial_threshold > 0.0, "Initial threshold must be positive" ); assert!(alpha > 0.0 && alpha <= 1.0, "Alpha must be in (0, 1]"); Self { threshold: initial_threshold, cumulative_dollar: 0.0, first_timestamp: None, current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, cumulative_volume: 0.0, last_price: 0.0, adaptive_mode: true, ewma_alpha: alpha, } } /// Get current threshold (for test compatibility) pub const fn get_threshold(&self) -> f64 { self.threshold } pub fn update( &mut self, price: f64, volume: f64, timestamp: DateTime, ) -> Option { // Validate inputs assert!(price >= 0.0, "Price cannot be negative"); assert!(volume >= 0.0, "Volume cannot be negative"); // Ignore zero-volume ticks if volume == 0.0 { return None; } let dollar_value = price * volume; self.cumulative_dollar += dollar_value; if self.current_open.is_none() { self.current_open = Some(price); self.first_timestamp = Some(timestamp); } self.current_high = self.current_high.max(price); self.current_low = self.current_low.min(price); self.cumulative_volume += volume; self.last_price = price; (self.cumulative_dollar >= self.threshold).then(|| { let bar = OHLCVBar { timestamp: self.first_timestamp.unwrap_or(timestamp), open: self.current_open.unwrap_or(price), high: self.current_high, low: self.current_low, close: self.last_price, volume: self.cumulative_volume, }; // Update threshold if adaptive mode (EWMA) if self.adaptive_mode { self.threshold = self.ewma_alpha * self.threshold + (1.0 - self.ewma_alpha) * self.cumulative_dollar; } self.reset(); bar }) } pub const fn threshold(&self) -> f64 { self.threshold } pub const fn cumulative_dollar(&self) -> f64 { self.cumulative_dollar } /// Get accumulated dollar volume (test compatibility alias) pub const fn get_accumulated(&self) -> f64 { self.cumulative_dollar } const fn reset(&mut self) { self.cumulative_dollar = 0.0; self.first_timestamp = None; self.current_open = None; self.current_high = f64::NEG_INFINITY; self.current_low = f64::INFINITY; self.cumulative_volume = 0.0; } } /// Imbalance Bar Sampler - Aggregates based on buy/sell imbalance /// /// Emits bars when cumulative imbalance exceeds threshold. /// Buy ticks (price increase) add to imbalance, sell ticks (price decrease) subtract. /// /// Based on Lopez de Prado (2018) - "Advances in Financial Machine Learning" /// /// Performance: <50μs per bar (Wave B target) /// /// # Example /// ``` /// use ml::features::alternative_bars::ImbalanceBarSampler; /// use chrono::Utc; /// /// let mut sampler = ImbalanceBarSampler::new(100.0, 100.0, Utc::now()); /// /// // Process ticks /// sampler.update(100.0, 10.0, Utc::now()); // Baseline /// sampler.update(101.0, 20.0, Utc::now()); // Buy tick: +20 imbalance /// sampler.update(102.0, 30.0, Utc::now()); // Buy tick: +30 imbalance /// /// // Bar emits when imbalance >= 100 /// if let Some(bar) = sampler.update(103.0, 60.0, Utc::now()) { /// println!("Bar: O={} H={} L={} C={} V={}", bar.open, bar.high, bar.low, bar.close, bar.volume); /// } /// ``` #[derive(Debug)] pub struct ImbalanceBarSampler { /// Imbalance threshold for bar formation threshold: f64, /// Cumulative buy/sell imbalance (positive=buy, negative=sell) cumulative_imbalance: f64, /// Previous price for tick direction classification previous_price: Option, /// Last tick direction (+1=buy, -1=sell, 0=unchanged) last_direction: i8, /// OHLCV tracking first_timestamp: Option>, current_open: Option, current_high: f64, current_low: f64, cumulative_volume: f64, last_price: f64, /// EWMA threshold adaptation adaptive_mode: bool, ewma_alpha: f64, } impl ImbalanceBarSampler { /// Create a new imbalance bar sampler with fixed threshold /// /// # Arguments /// * `initial_price` - Starting price (for direction classification) /// * `threshold` - Imbalance threshold (e.g., 100.0 for ±100 units) /// * `timestamp` - Initial timestamp pub fn new(initial_price: f64, threshold: f64, timestamp: DateTime) -> Self { assert!(threshold > 0.0, "Threshold must be greater than 0"); Self { threshold, cumulative_imbalance: 0.0, previous_price: Some(initial_price), last_direction: 0, first_timestamp: Some(timestamp), current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, cumulative_volume: 0.0, last_price: initial_price, adaptive_mode: false, ewma_alpha: 0.0, } } /// Create adaptive imbalance bar sampler with EWMA threshold adjustment /// /// # Arguments /// * `initial_price` - Starting price /// * `threshold` - Initial imbalance threshold /// * `timestamp` - Initial timestamp /// * `alpha` - EWMA smoothing factor (0 < alpha <= 1, e.g., 0.1) pub fn new_with_ewma( initial_price: f64, threshold: f64, timestamp: DateTime, alpha: f64, ) -> Self { assert!(threshold > 0.0, "Threshold must be greater than 0"); assert!(alpha > 0.0 && alpha <= 1.0, "Alpha must be in (0, 1]"); Self { threshold, cumulative_imbalance: 0.0, previous_price: Some(initial_price), last_direction: 0, first_timestamp: Some(timestamp), current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, cumulative_volume: 0.0, last_price: initial_price, adaptive_mode: true, ewma_alpha: alpha, } } /// Process a tick and return completed bar if threshold exceeded /// /// # Arguments /// * `price` - Trade price /// * `volume` - Trade volume /// * `timestamp` - Trade timestamp /// /// # Returns /// `Some(OHLCVBar)` if imbalance threshold was exceeded, `None` otherwise /// /// # Tick Classification /// - Buy tick: `price > previous_price` → direction = +1 /// - Sell tick: `price < previous_price` → direction = -1 /// - Unchanged: `price == previous_price` → use `last_direction` (`MLFinLab` convention) pub fn update( &mut self, price: f64, volume: f64, timestamp: DateTime, ) -> Option { // Ignore zero-volume ticks if volume == 0.0 { return None; } // Initialize on first tick if self.current_open.is_none() { self.current_open = Some(price); self.first_timestamp = Some(timestamp); } // Classify tick direction let direction = if let Some(prev_price) = self.previous_price { if price > prev_price { 1 // Buy tick } else if price < prev_price { -1 // Sell tick } else { // Price unchanged: use previous direction (MLFinLab convention) self.last_direction } } else { 0 // First tick has no direction }; // Update imbalance: positive for buys, negative for sells let imbalance_contribution = direction as f64 * volume; self.cumulative_imbalance += imbalance_contribution; // Update OHLCV self.current_high = self.current_high.max(price); self.current_low = self.current_low.min(price); self.cumulative_volume += volume; self.last_price = price; // Update state for next tick self.previous_price = Some(price); self.last_direction = direction; // Check if bar should be emitted (absolute imbalance >= threshold) (self.cumulative_imbalance.abs() >= self.threshold).then(|| { let bar = OHLCVBar { timestamp: self.first_timestamp.unwrap_or(timestamp), open: self.current_open.unwrap_or(price), high: self.current_high, low: self.current_low, close: self.last_price, volume: self.cumulative_volume, }; // Update threshold if adaptive mode (EWMA) if self.adaptive_mode { let observed_imbalance = self.cumulative_imbalance.abs(); self.threshold = self.ewma_alpha * self.threshold + (1.0 - self.ewma_alpha) * observed_imbalance; } // Reset for next bar self.reset(); bar }) } /// Get current cumulative imbalance pub const fn get_imbalance(&self) -> f64 { self.cumulative_imbalance } /// Get current threshold pub const fn get_threshold(&self) -> f64 { self.threshold } /// Reset the sampler state for a new bar const fn reset(&mut self) { self.cumulative_imbalance = 0.0; self.first_timestamp = None; self.current_open = None; self.current_high = f64::NEG_INFINITY; self.current_low = f64::INFINITY; self.cumulative_volume = 0.0; // Keep previous_price and last_direction for continuity } } /// Run Bar Sampler - Aggregates based on consecutive directional ticks /// /// Emits bars when consecutive buy or sell ticks exceed threshold. /// A "run" is a sequence of ticks moving in the same direction. /// /// Based on Lopez de Prado (2018) - "Advances in Financial Machine Learning" /// /// Performance: <50μs per bar (Wave B target) /// /// # Example /// ``` /// use ml::features::alternative_bars::RunBarSampler; /// use chrono::Utc; /// /// let mut sampler = RunBarSampler::new(5); // 5 consecutive ticks in same direction /// /// // Send 5 buy ticks (price increasing) /// sampler.update(100.0, 10.0, Utc::now()); /// sampler.update(100.1, 10.0, Utc::now()); /// sampler.update(100.2, 10.0, Utc::now()); /// sampler.update(100.3, 10.0, Utc::now()); /// /// // 5th buy tick triggers bar /// if let Some(bar) = sampler.update(100.4, 10.0, Utc::now()) { /// println!("Bar: O={} H={} L={} C={} V={}", bar.open, bar.high, bar.low, bar.close, bar.volume); /// } /// ``` #[derive(Debug)] pub struct RunBarSampler { /// Threshold for consecutive directional ticks threshold: usize, /// Current run count (consecutive ticks in same direction) run_count: usize, /// Previous price for direction classification previous_price: Option, /// Current direction (+1=buy, -1=sell, 0=no direction) current_direction: i8, /// OHLCV tracking first_timestamp: Option>, current_open: Option, current_high: f64, current_low: f64, cumulative_volume: f64, last_price: f64, } impl RunBarSampler { /// Create a new run bar sampler /// /// # Arguments /// * `threshold` - Number of consecutive directional ticks per bar (e.g., 5, 10) /// /// # Panics /// Panics if threshold is 0 pub fn new(threshold: usize) -> Self { assert!(threshold > 0, "Threshold must be greater than 0"); Self { threshold, run_count: 0, previous_price: None, current_direction: 0, first_timestamp: None, current_open: None, current_high: f64::NEG_INFINITY, current_low: f64::INFINITY, cumulative_volume: 0.0, last_price: 0.0, } } /// Process a tick and return completed bar if run threshold reached /// /// # Arguments /// * `price` - Trade price /// * `volume` - Trade volume /// * `timestamp` - Trade timestamp /// /// # Returns /// `Some(OHLCVBar)` if consecutive run threshold was reached, `None` otherwise /// /// # Direction Classification /// - Buy tick: `price > previous_price` → direction = +1 /// - Sell tick: `price < previous_price` → direction = -1 /// - Unchanged: `price == previous_price` → no direction (run continues) /// - Direction change: Resets `run_count` to 1 pub fn update( &mut self, price: f64, volume: f64, timestamp: DateTime, ) -> Option { // Determine tick direction FIRST (before updating state) let direction = if let Some(prev_price) = self.previous_price { if price > prev_price { 1 // Buy tick } else if price < prev_price { -1 // Sell tick } else { 0 // No direction (price unchanged) } } else { 0 // First tick has no direction }; // Direction change detection: if we have a new direction (not 0) different from current let direction_changed = direction != 0 && self.current_direction != 0 && direction != self.current_direction; if direction_changed { // Direction changed - emit bar if threshold was met in previous run if self.run_count >= self.threshold { let bar = OHLCVBar { timestamp: self.first_timestamp.unwrap_or(timestamp), open: self.current_open.unwrap_or(price), high: self.current_high, low: self.current_low, close: self.last_price, volume: self.cumulative_volume, }; // Reset and start new run with current tick self.reset(); self.current_open = Some(price); self.first_timestamp = Some(timestamp); self.current_high = price; self.current_low = price; self.cumulative_volume = volume; self.last_price = price; self.run_count = 1; self.current_direction = direction; self.previous_price = Some(price); return Some(bar); } else { // Direction changed but threshold not met - reset and start new run self.reset(); self.current_open = Some(price); self.first_timestamp = Some(timestamp); self.current_high = price; self.current_low = price; self.cumulative_volume = volume; self.last_price = price; self.run_count = 1; self.current_direction = direction; self.previous_price = Some(price); return None; } } // No direction change - continue accumulating // Initialize on first tick if self.current_open.is_none() { self.current_open = Some(price); self.first_timestamp = Some(timestamp); } // Update OHLCV accumulation self.current_high = self.current_high.max(price); self.current_low = self.current_low.min(price); self.cumulative_volume += volume; self.last_price = price; // Update previous price for next comparison self.previous_price = Some(price); // Increment run count on EVERY tick self.run_count += 1; // Set direction on first directional tick if direction != 0 && self.current_direction == 0 { self.current_direction = direction; } // Check if threshold reached AND we have a direction (self.run_count >= self.threshold && self.current_direction != 0).then(|| { let bar = OHLCVBar { timestamp: self.first_timestamp.unwrap_or(timestamp), open: self.current_open.unwrap_or(price), high: self.current_high, low: self.current_low, close: self.last_price, volume: self.cumulative_volume, }; // Reset for next bar self.reset(); bar }) } /// Get the run threshold pub const fn threshold(&self) -> usize { self.threshold } /// Get the current run count pub const fn run_count(&self) -> usize { self.run_count } /// Get the current direction (-1 for sell, 0 for neutral, +1 for buy) pub const fn direction(&self) -> i8 { self.current_direction } /// Reset the sampler state for a new bar pub const fn reset(&mut self) { self.run_count = 0; self.current_direction = 0; self.first_timestamp = None; self.current_open = None; self.current_high = f64::NEG_INFINITY; self.current_low = f64::INFINITY; self.cumulative_volume = 0.0; // Keep previous_price for continuity } }