// ml/tests/dollar_bars_test.rs // Dollar Bar Sampling Tests (TDD Approach) // Written FIRST before implementation use chrono::{DateTime, TimeZone, Utc}; use ml::features::alternative_bars::{DollarBarSampler, OHLCVBar}; #[test] fn test_dollar_bar_basic_formation() { // Test: Bar forms when dollar volume threshold is reached let mut sampler = DollarBarSampler::new(1000.0); // $1000 threshold let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // 2021-01-01 // First tick: $100 * 5 = $500 (accumulated: $500, no bar) let result1 = sampler.update(100.0, 5.0, base_time); assert!(result1.is_none(), "Should not emit bar yet"); // Second tick: $110 * 6 = $660 (accumulated: $1160, bar emitted) let result2 = sampler.update(110.0, 6.0, base_time + chrono::Duration::seconds(1)); assert!(result2.is_some(), "Should emit bar when threshold exceeded"); let bar = result2.unwrap(); assert_eq!(bar.open, 100.0, "Open should be first price"); assert_eq!(bar.close, 110.0, "Close should be last price"); assert_eq!(bar.volume, 11.0, "Volume should sum to 11"); } #[test] fn test_dollar_bar_ohlcv_calculation() { // Test: OHLCV values calculated correctly across multiple ticks let mut sampler = DollarBarSampler::new(5000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); let ticks = vec![ (100.0, 10.0, 0), // $1000 (105.0, 15.0, 1), // $1575 (95.0, 20.0, 2), // $1900 (102.0, 10.0, 3), // $1020 (total: $5495, exceeds threshold) ]; let mut result = None; for (price, vol, offset) in ticks { result = sampler.update(price, vol, base_time + chrono::Duration::seconds(offset)); } let bar = result.expect("Bar should be emitted"); assert_eq!(bar.open, 100.0, "Open: first tick price"); assert_eq!(bar.high, 105.0, "High: maximum price"); assert_eq!(bar.low, 95.0, "Low: minimum price"); assert_eq!(bar.close, 102.0, "Close: last tick price"); assert_eq!(bar.volume, 55.0, "Volume: sum of all ticks"); } #[test] fn test_dollar_bar_multiple_bars() { // Test: Multiple bars form correctly with threshold resets let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // First bar: $100 * 12 = $1200 let bar1 = sampler.update(100.0, 12.0, base_time); assert!(bar1.is_some(), "First bar should form"); // Second bar: $200 * 6 = $1200 let bar2 = sampler.update(200.0, 6.0, base_time + chrono::Duration::seconds(10)); assert!(bar2.is_some(), "Second bar should form"); let b1 = bar1.unwrap(); let b2 = bar2.unwrap(); assert_eq!(b1.open, 100.0); assert_eq!(b1.close, 100.0); assert_eq!(b2.open, 200.0); assert_eq!(b2.close, 200.0); } #[test] fn test_dollar_bar_accumulation_across_ticks() { // Test: Dollar volume accumulates correctly before threshold let mut sampler = DollarBarSampler::new(2000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // Tick 1: $100 * 5 = $500 assert!(sampler.update(100.0, 5.0, base_time).is_none()); // Tick 2: $100 * 5 = $500 (total: $1000) assert!(sampler .update(100.0, 5.0, base_time + chrono::Duration::seconds(1)) .is_none()); // Tick 3: $100 * 5 = $500 (total: $1500) assert!(sampler .update(100.0, 5.0, base_time + chrono::Duration::seconds(2)) .is_none()); // Tick 4: $100 * 6 = $600 (total: $2100, exceeds threshold) let bar = sampler.update(100.0, 6.0, base_time + chrono::Duration::seconds(3)); assert!(bar.is_some(), "Bar should form after accumulation"); assert_eq!(bar.unwrap().volume, 21.0, "Total volume should be 21"); } #[test] fn test_dollar_bar_zero_volume_ignored() { // Test: Zero volume ticks don't contribute to dollar volume let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // Valid tick assert!(sampler.update(100.0, 5.0, base_time).is_none()); // Zero volume tick (should be ignored) assert!(sampler .update(110.0, 0.0, base_time + chrono::Duration::seconds(1)) .is_none()); // Another valid tick to complete bar let bar = sampler.update(100.0, 6.0, base_time + chrono::Duration::seconds(2)); assert!(bar.is_some(), "Bar should form ignoring zero volume"); let b = bar.unwrap(); assert_eq!(b.volume, 11.0, "Volume should exclude zero-volume tick"); assert_eq!(b.open, 100.0, "Open should be first valid price"); } #[test] fn test_dollar_bar_large_single_trade() { // Test: Single trade exceeding threshold forms immediate bar let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // Single large trade: $100 * 50 = $5000 (exceeds threshold) let bar = sampler.update(100.0, 50.0, base_time); assert!(bar.is_some(), "Large trade should form immediate bar"); let b = bar.unwrap(); assert_eq!(b.open, 100.0); assert_eq!(b.close, 100.0); assert_eq!(b.high, 100.0); assert_eq!(b.low, 100.0); assert_eq!(b.volume, 50.0); } #[test] fn test_dollar_bar_price_gaps() { // Test: Large price gaps handled correctly let mut sampler = DollarBarSampler::new(10000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); let ticks = vec![ (100.0, 20.0, 0), // $2000 (150.0, 30.0, 1), // $4500 (gap up) (90.0, 40.0, 2), // $3600 (gap down, total: $10100) ]; let mut result = None; for (price, vol, offset) in ticks { result = sampler.update(price, vol, base_time + chrono::Duration::seconds(offset)); } let bar = result.expect("Bar should form despite gaps"); assert_eq!(bar.high, 150.0, "High should capture gap up"); assert_eq!(bar.low, 90.0, "Low should capture gap down"); } #[test] fn test_dollar_bar_timestamp_tracking() { // Test: Bar timestamps reflect first and last tick times let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); sampler.update(100.0, 5.0, base_time); let bar = sampler.update(100.0, 6.0, base_time + chrono::Duration::seconds(10)); let b = bar.expect("Bar should form"); assert_eq!( b.timestamp, base_time, "Timestamp should be first tick time" ); } #[test] fn test_dollar_bar_exact_threshold() { // Test: Bar forms when exactly hitting threshold let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // Exactly $1000 let bar = sampler.update(100.0, 10.0, base_time); assert!(bar.is_some(), "Bar should form at exact threshold"); } #[test] fn test_dollar_bar_adaptive_threshold_ewma() { // Test: Adaptive threshold using EWMA of bar dollar volumes let mut sampler = DollarBarSampler::new_adaptive(1000.0, 0.95); // alpha=0.95 for EWMA let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // First bar: $1200 let bar1 = sampler.update(100.0, 12.0, base_time); assert!(bar1.is_some()); // Threshold should adapt based on EWMA let new_threshold = sampler.get_threshold(); assert!( new_threshold > 1000.0, "Threshold should increase after large bar" ); assert!( new_threshold < 1200.0, "Threshold should be smoothed by EWMA" ); } #[test] fn test_dollar_bar_performance_benchmark() { // Test: Performance constraint <50μs per tick (soft target, not hard assertion) use std::time::Instant; let mut sampler = DollarBarSampler::new(100000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); let start = Instant::now(); let iterations = 10000; for i in 0..iterations { sampler.update( 100.0 + (i as f64 * 0.1), 5.0, base_time + chrono::Duration::milliseconds(i), ); } let elapsed = start.elapsed(); let per_tick = elapsed.as_nanos() / iterations as u128; println!("Performance: {}ns per tick (target: <50000ns)", per_tick); // Informational only - don't fail test on performance } #[test] fn test_dollar_bar_fractional_shares() { // Test: Fractional share volumes handled correctly let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // $100 * 10.5 = $1050 let bar = sampler.update(100.0, 10.5, base_time); assert!(bar.is_some()); let b = bar.unwrap(); assert_eq!(b.volume, 10.5, "Fractional volumes should be preserved"); } #[test] fn test_dollar_bar_high_frequency_ticks() { // Test: Many small ticks accumulate correctly let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); let mut bars_formed = 0; // 500 ticks of $10 each = $5000 total = 5 bars for i in 0..500 { if sampler .update( 100.0, 0.1, // $100 * 0.1 = $10 per tick base_time + chrono::Duration::milliseconds(i), ) .is_some() { bars_formed += 1; } } assert_eq!(bars_formed, 5, "Should form 5 bars from 500 ticks"); } #[test] fn test_dollar_bar_negative_prices_rejected() { // Test: Negative prices are rejected (invalid data) let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // This should panic or return error (depending on implementation choice) // For now, test that it doesn't form a bar let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { sampler.update(-100.0, 10.0, base_time) })); // Either panics or returns None/Error assert!(result.is_err() || result.unwrap().is_none()); } #[test] fn test_dollar_bar_state_reset_after_emission() { // Test: Internal state resets correctly after bar emission let mut sampler = DollarBarSampler::new(1000.0); let base_time = Utc.timestamp_opt(1609459200, 0).unwrap(); // Form first bar sampler.update(100.0, 11.0, base_time); // Next tick should start fresh bar let result = sampler.update(200.0, 2.0, base_time + chrono::Duration::seconds(1)); assert!( result.is_none(), "Should not form bar immediately after reset" ); // Complete second bar let bar2 = sampler.update(200.0, 3.0, base_time + chrono::Duration::seconds(2)); assert!(bar2.is_some()); let b2 = bar2.unwrap(); assert_eq!( b2.open, 200.0, "New bar should start with first price after reset" ); }