Bottleneck B of two: replaces the sequential `ImbalanceBarSampler` walk
in `mbp10_to_imbalance_bars` with a two-pass parallel decomposition.
Bit-identical to sequential — verified by 6/6 passing tests in
`crates/ml-features/tests/imbalance_bars_parallel_bit_equiv_test.rs` at
both dense (T=20, ~23k bars) and sparse (T=500, ~300 bars) emission
regimes, exact 0.0 diff on every f64 field.
Why two-pass (NOT time-bucket sharding)
=======================================
Time-bucket sharding (the SP20 OFI pattern in
`compute_ofi_per_bar_parallel`) bit-equates to sequential because OFI
features derive from BOUNDED rolling windows (VPIN ≤50, Kyle ≤100,
trade-imb ≤100, OFI-stats ≤300). After ≥window-size warmup updates,
two rolling buffers started from different initial states converge
bit-identically.
`ImbalanceBarSampler` is fundamentally different: its `cumulative_imbalance`
is a path integral that resets only when crossing ±threshold. There is
NO bounded-lookback window. Two replays of the same trade tape starting
from different cum_imb offsets emit at DIFFERENT trade indices, and the
phase offset is NOT guaranteed to vanish at any future trade — even
after many emissions, a bounded phase difference can persist
indefinitely. An earlier time-bucket-sharded prototype with WARMUP=10000
trades passed K=4/K=8 at threshold=20 (dense emissions, ~30 emissions
per shard's warmup window) but FAILED at threshold=500 with a 1-bar
count drift (golden=303, parallel=304) — exactly the kind of phase-
offset divergence predicted by the path-integral analysis.
Two-pass decomposition
======================
Pass 1 (sequential, lightweight): walk the trade tape ONCE tracking only
cum_imb, prev_price, last_direction. Record the trade index of every
emission-triggering trade as the end of a segment. No OHLCV state, no
Vec<OHLCVBar> allocation, no per-trade conditional bar construction.
O(N) simple arithmetic — for 50k-2M trades it runs in 1-15ms.
Pass 2 (parallel rayon): each emission segment [boundary[i-1]+1,
boundary[i]] produces exactly one bar via independent OHLCV reduction
over its trade slice. Segments are fully independent; `par_iter()` over
segment ranges is trivially correct.
Bit-equivalence guarantee
=========================
Pass 1 mirrors `ImbalanceBarSampler::update` arithmetic verbatim:
- zero-volume early-return (alternative_bars.rs:484-486)
- direction tie-break (alternative_bars.rs:495-506)
- cum_imb update (alternative_bars.rs:508-510)
- emission threshold check (alternative_bars.rs:522-523)
- prev_price/last_direction kept across emissions (reset() at :558-566)
- NO trailing-partial-bar flush (matches sequential exactly)
Pass 2's per-segment OHLCV reduction matches the sampler's per-bar OHLCV
update verbatim: open = first non-zero-volume trade in segment, close =
last non-zero-volume trade, high/low = max/min over non-zero-volume
trades, volume = sum, timestamp = open's timestamp.
Performance (release-mode bench, 16-thread rayon pool)
======================================================
n=100k: seq=1.39ms, par=1.88ms, speedup=0.74x (rayon overhead dominates)
n=500k: seq=8.04ms, par=3.35ms, speedup=2.40x
n=2M: seq=27.59ms, par=12.39ms, speedup=2.23x
Speedup is bounded by Pass 1 (sequential, ~5-7ms at 2M trades) since
Pass 2 (parallel, ~2-3ms at 2M trades on 16 threads) is ~3x faster
than Pass 1's sequential floor. At production scale (50k-500k trades
per `mbp10_to_imbalance_bars` call) we get 2-2.4x speedup over the
all-sequential baseline.
The `min_bars_per_task` cutoff falls back to a sequential Pass 2 when
segment count < 256 (the rayon spawn overhead exceeds the parallel
benefit). Tunable via the second function arg.
Files changed
=============
- crates/ml-features/src/alternative_bars.rs (+1 -1):
derive `Clone` on `ImbalanceBarSampler` for parallel-shard use
(carried through this commit even though Pass 1's hand-rolled walk
in `mbp10_loader.rs` doesn't need it — keeps the type clonable for
future test/bench infrastructure).
- crates/ml-features/src/lib.rs (+3 -2):
re-export `imbalance_bars_parallel`, `imbalance_bars_sequential`,
`DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK`.
- crates/ml-features/src/mbp10_loader.rs (+253 -15):
new `imbalance_bars_parallel`, `imbalance_bars_sequential`,
`DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK` const. Replace the inline
sampler-walk loop in `mbp10_to_imbalance_bars` with a call into
`imbalance_bars_parallel`. Module note explains why time-bucket
sharding doesn't apply.
- crates/ml-features/tests/imbalance_bars_parallel_bit_equiv_test.rs
(+272 NEW): hermetic synthetic-trade bit-equivalence tests at:
* default cutoff (par_iter Pass 2)
* forced par_iter (min_bars_per_task=1)
* forced sequential Pass 2 (min_bars_per_task=usize::MAX)
* empty input
* small input (Pass 2 below par cutoff)
* high-threshold sparse emissions (the case that broke the
time-bucket sharding prototype). All pass exact 0.0 diff.
Also note: 293/293 ml-features lib tests pass (no regression).
Pairs with the file-level par_iter trade extraction in
8f5c64e10 (Bottleneck A). Together they parallelise both halves of
`mbp10_to_imbalance_bars`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
273 lines
9.8 KiB
Rust
273 lines
9.8 KiB
Rust
//! Bit-equivalence test for `imbalance_bars_parallel` vs.
|
||
//! `imbalance_bars_sequential` (SP20 Bottleneck B, 2026-05-10).
|
||
//!
|
||
//! Asserts that the two-pass parallel implementation produces the SAME
|
||
//! imbalance bars as the single-threaded reference. Bars are constructed
|
||
//! from finite f64 reductions (sums, comparisons over identical trade
|
||
//! slices), so we expect EXACT equality — no tolerance.
|
||
//!
|
||
//! Test strategy
|
||
//! =============
|
||
//! 1. Generate synthetic `Mbp10Trade` ticks with a deterministic price
|
||
//! walk + variable volumes. The walk drives both buy/sell direction
|
||
//! transitions AND occasional unchanged-price ticks (which exercise
|
||
//! the `last_direction` branch).
|
||
//! 2. Run `imbalance_bars_sequential` to capture the golden bars.
|
||
//! 3. Run `imbalance_bars_parallel` (always Pass 1 sequential to find
|
||
//! emission boundaries; Pass 2 parallel rayon over segments). Vary
|
||
//! `min_bars_per_task` to exercise both the sequential-fallback and
|
||
//! par_iter cutoff paths.
|
||
//! 4. Assert bar lists are identical: same length, every field bitwise
|
||
//! equal (timestamp, OHLCV all f64-bitwise + DateTime equal).
|
||
//!
|
||
//! Why synthetic data: the test must be hermetic — no dependence on
|
||
//! `test_data/futures-baseline-mbp10`. The walk produces dense (low-T)
|
||
//! and sparse (high-T) emission regimes both, exercising the cutoff path.
|
||
//!
|
||
//! Why two-pass (NOT time-bucket sharding): `ImbalanceBarSampler.cum_imb`
|
||
//! is path-dependent with no bounded-lookback window. Time-bucket sharding
|
||
//! with warmup overlap (the OFI pattern) does NOT yield bit-identical
|
||
//! emission indices — a phase offset persists indefinitely between shard-
|
||
//! local and sequential walks. The two-pass decomposition runs Pass 1
|
||
//! sequentially to capture the EXACT emission boundary set, then
|
||
//! parallelises only the per-segment OHLCV reduction (which is trivially
|
||
//! independent across segments).
|
||
//!
|
||
//! If this test fails: the two-pass implementation has diverged from
|
||
//! sequential semantics. Audit Pass 1's `update()` mirroring (direction
|
||
//! tie-break, zero-volume early-return) and Pass 2's segment-OHLCV
|
||
//! reduction. See the doc comment on `imbalance_bars_parallel` in
|
||
//! `mbp10_loader.rs`.
|
||
|
||
use chrono::{DateTime, TimeZone, Utc};
|
||
use ml_features::{
|
||
imbalance_bars_parallel, imbalance_bars_sequential, Mbp10Trade, OHLCVBar,
|
||
DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK,
|
||
};
|
||
|
||
/// Generate `n` synthetic trade ticks with a deterministic price/volume walk.
|
||
///
|
||
/// The walk produces:
|
||
/// - Buy ticks (price up) on `i % 5 ∈ {0, 1}`
|
||
/// - Sell ticks (price down) on `i % 5 ∈ {2, 3}`
|
||
/// - Unchanged-price ticks on `i % 5 == 4` (exercises `last_direction`)
|
||
/// - Volume varies by index parity (volumes 1..30) so imbalance accumulates
|
||
/// asymmetrically and emissions land at varied trade indices.
|
||
///
|
||
/// At ES.FUT-style threshold=20, this generates a bar emission roughly every
|
||
/// few hundred trades — well below the 10_000-trade warmup, so every shard's
|
||
/// warmup window covers MANY emissions, making the bit-equivalence guarantee
|
||
/// rock-solid.
|
||
fn make_trades(n: usize, base_price: f64) -> Vec<Mbp10Trade> {
|
||
let start: DateTime<Utc> = Utc.with_ymd_and_hms(2025, 1, 1, 9, 30, 0).unwrap();
|
||
let mut trades = Vec::with_capacity(n);
|
||
let mut price = base_price;
|
||
let mut last_change_dir: f64 = 0.0;
|
||
for i in 0..n {
|
||
// Price walk: deterministic but exercises all three direction branches.
|
||
let r = i % 5;
|
||
let dir = match r {
|
||
0 | 1 => 1.0,
|
||
2 | 3 => -1.0,
|
||
_ => 0.0, // unchanged
|
||
};
|
||
if dir != 0.0 {
|
||
price += dir * 0.25;
|
||
last_change_dir = dir;
|
||
}
|
||
// Volume: vary 1..30 by index, occasionally inject larger blocks.
|
||
let volume = if i % 47 == 0 {
|
||
(((i % 17) + 5) * 3) as f64 // larger blocks at sparse positions
|
||
} else {
|
||
((i % 29) + 1) as f64
|
||
};
|
||
let timestamp = start + chrono::Duration::milliseconds(i as i64 * 10);
|
||
trades.push(Mbp10Trade {
|
||
price,
|
||
volume,
|
||
timestamp,
|
||
is_buy: last_change_dir >= 0.0, // unused by sampler; consistent for completeness
|
||
instrument_id: 1,
|
||
});
|
||
}
|
||
trades
|
||
}
|
||
|
||
/// Compare two `OHLCVBar` slices field-by-field with bitwise f64 equality
|
||
/// for floating-point fields. Returns the first mismatching index + a
|
||
/// human-readable summary, or `Ok(())` if identical.
|
||
fn assert_bars_bitwise_equal(golden: &[OHLCVBar], candidate: &[OHLCVBar], label: &str) {
|
||
assert_eq!(
|
||
golden.len(),
|
||
candidate.len(),
|
||
"{}: bar count mismatch (golden={}, candidate={})",
|
||
label,
|
||
golden.len(),
|
||
candidate.len()
|
||
);
|
||
for (i, (g, c)) in golden.iter().zip(candidate.iter()).enumerate() {
|
||
assert_eq!(g.timestamp, c.timestamp, "{}: bar[{}] timestamp mismatch", label, i);
|
||
assert_eq!(
|
||
g.open.to_bits(),
|
||
c.open.to_bits(),
|
||
"{}: bar[{}] open: golden={:?}, candidate={:?}",
|
||
label,
|
||
i,
|
||
g.open,
|
||
c.open
|
||
);
|
||
assert_eq!(
|
||
g.high.to_bits(),
|
||
c.high.to_bits(),
|
||
"{}: bar[{}] high: golden={:?}, candidate={:?}",
|
||
label,
|
||
i,
|
||
g.high,
|
||
c.high
|
||
);
|
||
assert_eq!(
|
||
g.low.to_bits(),
|
||
c.low.to_bits(),
|
||
"{}: bar[{}] low: golden={:?}, candidate={:?}",
|
||
label,
|
||
i,
|
||
g.low,
|
||
c.low
|
||
);
|
||
assert_eq!(
|
||
g.close.to_bits(),
|
||
c.close.to_bits(),
|
||
"{}: bar[{}] close: golden={:?}, candidate={:?}",
|
||
label,
|
||
i,
|
||
g.close,
|
||
c.close
|
||
);
|
||
assert_eq!(
|
||
g.volume.to_bits(),
|
||
c.volume.to_bits(),
|
||
"{}: bar[{}] volume: golden={:?}, candidate={:?}",
|
||
label,
|
||
i,
|
||
g.volume,
|
||
c.volume
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn imbalance_bars_parallel_matches_sequential_default_cutoff() {
|
||
// ~50k trades × threshold=20 → many hundreds of emissions. Default
|
||
// `min_bars_per_task` triggers the parallel rayon path in Pass 2.
|
||
let trades = make_trades(50_000, 4500.0);
|
||
let threshold = 20.0_f64;
|
||
|
||
let golden = imbalance_bars_sequential(&trades, threshold);
|
||
assert!(
|
||
!golden.is_empty(),
|
||
"synthetic walk should produce at least one bar; produced 0"
|
||
);
|
||
|
||
let parallel = imbalance_bars_parallel(
|
||
&trades,
|
||
threshold,
|
||
rayon::current_num_threads(),
|
||
DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK,
|
||
);
|
||
|
||
assert_bars_bitwise_equal(&golden, ¶llel, "default-cutoff");
|
||
println!(
|
||
"default-cutoff: {} bars, bit-identical to sequential",
|
||
golden.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn imbalance_bars_parallel_matches_sequential_force_par_iter() {
|
||
// min_bars_per_task = 1 forces the par_iter path even for tiny segment
|
||
// counts — exercises rayon execution explicitly.
|
||
let trades = make_trades(50_000, 4500.0);
|
||
let threshold = 20.0_f64;
|
||
|
||
let golden = imbalance_bars_sequential(&trades, threshold);
|
||
let parallel = imbalance_bars_parallel(&trades, threshold, 8, 1);
|
||
|
||
assert_bars_bitwise_equal(&golden, ¶llel, "force-par-iter");
|
||
println!(
|
||
"force-par-iter: {} bars, bit-identical to sequential",
|
||
golden.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn imbalance_bars_parallel_force_sequential_pass2() {
|
||
// min_bars_per_task = usize::MAX forces the sequential Pass 2 fallback
|
||
// even when many segments exist.
|
||
let trades = make_trades(5_000, 4500.0);
|
||
let threshold = 20.0_f64;
|
||
|
||
let golden = imbalance_bars_sequential(&trades, threshold);
|
||
let parallel = imbalance_bars_parallel(&trades, threshold, 1, usize::MAX);
|
||
|
||
assert_bars_bitwise_equal(&golden, ¶llel, "force-sequential-pass2");
|
||
}
|
||
|
||
#[test]
|
||
fn imbalance_bars_parallel_empty_input() {
|
||
let golden = imbalance_bars_sequential(&[], 20.0);
|
||
let parallel = imbalance_bars_parallel(
|
||
&[],
|
||
20.0,
|
||
8,
|
||
DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK,
|
||
);
|
||
assert!(golden.is_empty());
|
||
assert!(parallel.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn imbalance_bars_parallel_small_input() {
|
||
// 100 trades with low threshold to force emissions. Pass 2 segment
|
||
// count will be small (a few segments), which tests the cutoff path.
|
||
let trades = make_trades(100, 4500.0);
|
||
let threshold = 5.0_f64;
|
||
|
||
let golden = imbalance_bars_sequential(&trades, threshold);
|
||
let parallel = imbalance_bars_parallel(
|
||
&trades,
|
||
threshold,
|
||
8,
|
||
DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK,
|
||
);
|
||
|
||
assert_bars_bitwise_equal(&golden, ¶llel, "small-input");
|
||
}
|
||
|
||
#[test]
|
||
fn imbalance_bars_parallel_high_threshold_few_emissions() {
|
||
// High threshold → very sparse emissions across 50k trades. Pass 1
|
||
// captures the exact emission set, so Pass 2 is bit-identical no
|
||
// matter how few segments. THIS WAS THE FAILING CASE for the earlier
|
||
// time-bucket-sharded prototype (phase-offset divergence between
|
||
// shard-local and sequential cum_imb). The two-pass approach
|
||
// resolves it definitively.
|
||
let trades = make_trades(50_000, 4500.0);
|
||
let threshold = 500.0_f64;
|
||
|
||
let golden = imbalance_bars_sequential(&trades, threshold);
|
||
let parallel_default = imbalance_bars_parallel(
|
||
&trades,
|
||
threshold,
|
||
4,
|
||
DEFAULT_IMBALANCE_BAR_MIN_BARS_PER_TASK,
|
||
);
|
||
let parallel_force_par = imbalance_bars_parallel(&trades, threshold, 8, 1);
|
||
|
||
assert_bars_bitwise_equal(&golden, ¶llel_default, "high-threshold default");
|
||
assert_bars_bitwise_equal(&golden, ¶llel_force_par, "high-threshold force-par");
|
||
println!(
|
||
"high-threshold (T=500): {} bars over 50k trades, default + force-par bit-identical",
|
||
golden.len()
|
||
);
|
||
}
|