perf: bit-exact batch fast paths + streaming-first benchmark docs (#202)

## Summary
- Dedicated batch fast paths for **EMA, RSI, Bollinger, MACD and ATR** (used by the Python bindings): one allocation filled in a single pass, warmup encoded as `NaN`, no per-element `Option` or input re-validation. Each is **bit-for-bit equal** to replaying `update` — SMA/Bollinger keep the drift-reseed cadence, the EMA-family keep the seed division and `mul_add` recurrences. Adds the `BatchNanExt` extension trait.
- **Cross-library benchmark refresh**: `compare_libraries.py` reports the median across timing rounds (`--rounds` / `--streaming-rounds`), gains `--skip-batch` / `--skip-streaming`, and runs every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` drives the batch fast paths against `kand`.
- **README** benchmark section reordered streaming-first (the order-of-magnitude result), with measured TA-Lib/tulipy/pandas-ta numbers in place of the CI-only placeholders.

## Impact
- Python batch ~2× faster on EMA/RSI/MACD/ATR; streaming path unchanged.
- The `batch == streaming` equivalence stays bit-exact.

## Verification
- `cargo fmt` · `cargo clippy --workspace --all-targets --all-features -- -D warnings` (clean)
- `cargo test --workspace --all-features` — 3782 unit + 420 doc tests pass
- Python `pytest` — streaming-vs-batch, known-values, input-validation, smoke pass

## Notes
- Node/WASM bindings keep their existing batch; the fast paths are Python-only for now.
This commit is contained in:
kingchenc
2026-06-08 00:17:58 +02:00
committed by GitHub
parent e97c3389fe
commit 05fe7ffa90
14 changed files with 1400 additions and 296 deletions
+34
View File
@@ -90,6 +90,29 @@ pub trait BatchExt: Indicator {
impl<T: Indicator> BatchExt for T {}
/// Fast batch for scalar `f64 -> f64` indicators.
///
/// The generic [`BatchExt::batch`] returns `Vec<Option<f64>>` — 16 bytes per
/// element (no niche fits an arbitrary `f64`), which a caller wanting a dense
/// `f64` series then has to walk a second time to map warmup `None`s to `NaN`.
/// This skips both the wide intermediate and the second pass: one allocation,
/// one pass, warmup encoded as `NaN`. The default body is bit-identical to
/// replaying `update`; indicators with a vectorizable closed form override it
/// with an inherent `batch_nan` of the same name, which wins method resolution
/// over this trait default.
pub trait BatchNanExt: Indicator<Input = f64, Output = f64> {
/// One `f64` per input, warmup positions filled with `NaN`.
fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
let mut out = Vec::with_capacity(inputs.len());
for &x in inputs {
out.push(self.update(x).unwrap_or(f64::NAN));
}
out
}
}
impl<T: Indicator<Input = f64, Output = f64>> BatchNanExt for T {}
/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi,
/// Point-and-Figure) that turns a candle stream into a stream of price-driven
/// bars.
@@ -297,6 +320,17 @@ mod tests {
assert_eq!(out, vec![Some(1.0), Some(2.0), Some(3.0)]);
}
/// The blanket [`BatchNanExt::batch_nan`] default (used by every scalar
/// indicator without an inherent fast path) maps `update` outputs to a dense
/// `f64` series, warmup `None` becoming `NaN`. `Identity` is always ready, so
/// the result is just the inputs back.
#[test]
fn batch_nan_default_maps_none_to_nan() {
let mut id = Identity::default();
let out = id.batch_nan(&[1.0, 2.0, 3.0]);
assert_eq!(out, vec![1.0, 2.0, 3.0]);
}
#[test]
fn chain_pipes_first_into_second() {
let mut c = Chain::new(Doubler::default(), Doubler::default());