Files
foxhunt/crates/ml-features/src/alternative_bars.rs
jgrusewski 7ef92983f9 fix(clippy): apply cargo clippy --fix across workspace
Mechanical auto-fixes: redundant borrows, clone on Copy, or_insert_with,
single-char push_str, get(0) → first(), needless borrow, let_and_return.
150 files, no behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:17:51 +01:00

789 lines
25 KiB
Rust

//! 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<DateTime<Utc>>,
/// Opening price of the current bar
current_open: Option<f64>,
/// 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<Utc>,
) -> Option<OHLCVBar> {
// 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<DateTime<Utc>>,
current_open: Option<f64>,
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<Utc>,
) -> Option<OHLCVBar> {
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<DateTime<Utc>>,
current_open: Option<f64>,
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<Utc>,
) -> Option<OHLCVBar> {
// 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<f64>,
/// Last tick direction (+1=buy, -1=sell, 0=unchanged)
last_direction: i8,
/// OHLCV tracking
first_timestamp: Option<DateTime<Utc>>,
current_open: Option<f64>,
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<Utc>) -> 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<Utc>,
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<Utc>,
) -> Option<OHLCVBar> {
// 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<f64>,
/// Current direction (+1=buy, -1=sell, 0=no direction)
current_direction: i8,
/// OHLCV tracking
first_timestamp: Option<DateTime<Utc>>,
current_open: Option<f64>,
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<Utc>,
) -> Option<OHLCVBar> {
// 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
}
}