From 1682948b88d18751da70c92d65f5a136a4d5a29c Mon Sep 17 00:00:00 2001 From: Sean Koval Date: Sat, 19 Sep 2026 11:04:05 -0400 Subject: [PATCH] fix: consume the first tick threshold so features align with bars new_from_csv read the first threshold with a peek and left it in the iterator, so it was served again after the first bar closed and the next tick emitted a spurious one-tick bar. Thresholds [10, 20, 30] produced four rows, with every row after the first misaligned against the bars. Take the first threshold out of the iterator, as mlfinlab's generator does. The new test builds 30 synthetic ticks and asserts exactly three rows with average tick sizes 1, 2, 3; it fails with [1, 2, 2, 3] before this change. Fixes #67 Co-Authored-By: Claude Fable 5.1 --- .../openquant/src/microstructural_features.rs | 8 ++++-- .../tests/microstructural_features.rs | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/openquant/src/microstructural_features.rs b/crates/openquant/src/microstructural_features.rs index 23185603..58343782 100644 --- a/crates/openquant/src/microstructural_features.rs +++ b/crates/openquant/src/microstructural_features.rs @@ -649,9 +649,13 @@ impl MicrostructuralFeaturesGenerator { // Try multiple datetime formats (with/without fractional seconds) let _ = parse_datetime(&rec[0]).map_err(|_| "column 0 not datetime".to_string())?; } + // Take the first threshold *out of* the iterator. Peeking at it instead leaves + // it to be served again after the first bar closes, which emits a one-tick bar. + let mut tick_num_iter = tick_num_series.to_vec().into_iter(); + let current_bar_tick = tick_num_iter.next().unwrap_or(0); Ok(Self { - tick_num_iter: tick_num_series.to_vec().into_iter(), - current_bar_tick: tick_num_series.get(0).copied().unwrap_or(0), + tick_num_iter, + current_bar_tick, price_diff: Vec::new(), trade_size: Vec::new(), tick_rule: Vec::new(), diff --git a/crates/openquant/tests/microstructural_features.rs b/crates/openquant/tests/microstructural_features.rs index afa761e5..ec2729cf 100644 --- a/crates/openquant/tests/microstructural_features.rs +++ b/crates/openquant/tests/microstructural_features.rs @@ -251,3 +251,30 @@ fn test_first_generation_features() { assert!((mean(&bekker) - 0.001456).abs() < 1e-4); assert!((bekker[25] - 0.000517).abs() < 1e-4); } + +#[test] +fn test_feature_generator_emits_one_row_per_tick_threshold() { + // 30 ticks; trade size is 1 for ticks 1-10, 2 for 11-20, 3 for 21-30. With bars + // closing at ticks 10, 20 and 30 the average tick size per bar is exactly 1, 2, 3. + let path = std::env::temp_dir() + .join(format!("openquant_ticks_{}_one_row_per_threshold.csv", std::process::id())); + let mut csv = String::from("Date and Time,Price,Volume\n"); + for tick in 0..30 { + let price = 100.0 + (tick % 5) as f64 * 0.25; + csv.push_str(&format!("2011/07/31 23:31:{:02}.000,{price},{}\n", 10 + tick, tick / 10 + 1)); + } + std::fs::write(&path, csv).unwrap(); + + let mut gen = MicrostructuralFeaturesGenerator::new_from_csv( + path.to_str().unwrap(), + &[10, 20, 30], + None, + None, + ) + .unwrap(); + let feats = gen.get_features_from_csv(path.to_str().unwrap()).unwrap(); + std::fs::remove_file(&path).ok(); + + let avg_tick_sizes: Vec = feats.iter().map(|row| row[1]).collect(); + assert_eq!(avg_tick_sizes, vec![1.0, 2.0, 3.0]); +}