Honest tiered cross-library benchmark + streaming/batch perf (#186)

## Summary

An honest, tiered cross-library benchmark — and the optimization pass it triggered.

### Performance (wickra-core, outputs unchanged)
Profiling against the other Rust TA crates exposed real inefficiencies. Each
benchmarked indicator is now **5–79% faster** in both streaming and batch:

- **SMA, Bollinger**: flat `Box<[f64]>` ring buffers replace `VecDeque` (−69…79%).
- **RSI**: `100·ag/(ag+al)` collapses three divisions into one; Wilder smoothing
  hoists `1/period` out of the hot path (−46%).
- **ATR**: reciprocal hoisted (−42%).
- **EMA/RSI/ATR**: per-tick `Option<f64>` hot state → bare `f64` + ready flag.

Net result vs `kand`: Wickra now wins **RSI, Bollinger and ATR** (streaming), and
ties `ta-rs` on SMA — up from losing every indicator 1.5–6× before.

### Benchmark harness
New `crates/wickra-bench` (publish=false): a Criterion benchmark comparing Wickra
against `kand`, `ta-rs` and `yata` on an identical BTCUSDT candle series, in
streaming and batch modes. Peer APIs were verified against their source, not
guessed. Wired into the nightly `cross-library-bench` workflow as a separate job.

### Honest README
The benchmark section is rewritten into three layered tables (Rust core vs Rust
crates; Python vs the Python ecosystem) that **show the losses as well as the
wins**. The "only library that combines…" claim is gone; the new framing is
breadth + multi-language reach + the deliberate safety trade-off that costs raw
speed. Added an origin/why-slower rationale and a star CTA.

### Python benchmark
Added `tulipy` runners and expanded per-tick streaming coverage to SMA/EMA/RSI/
MACD/Bollinger. `bench.in`/`bench.txt` now lock `TA-Lib` + `tulipy` (hash-pinned);
`pandas-ta` stays out (it requires Python ≥ 3.12, the bench runs on 3.11).

### Notes
- TA-Lib/tulipy numbers in the README Python table are marked ⧗ — they are
  produced by the CI Linux job (C extensions don't build cleanly on every
  desktop), not measured locally.
- The matching `wickra-docs` prose update is committed separately and will be
  pushed with the release, per the docs-don't-lead-the-registries rule.

Verified locally: `cargo fmt`, `cargo test --workspace --all-features` (3413 core
+ bindings), `cargo clippy --workspace --all-targets --all-features -D warnings`,
Node build + 498 tests, and pytest all green.
This commit is contained in:
kingchenc
2026-06-06 20:57:31 +02:00
committed by GitHub
parent d2bc000892
commit aacb9280f1
18 changed files with 1413 additions and 141 deletions
+27 -10
View File
@@ -28,9 +28,17 @@ use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct Atr {
period: usize,
/// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
n_minus_1: f64,
/// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
/// divides.
inv_period: f64,
prev_close: Option<f64>,
seed_buf: Vec<f64>,
avg: Option<f64>,
/// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than
/// `Option<f64>` so the hot recurrence avoids an enum-tag read per tick.
avg: f64,
seeded: bool,
}
impl Atr {
@@ -45,9 +53,12 @@ impl Atr {
}
Ok(Self {
period,
n_minus_1: (period - 1) as f64,
inv_period: 1.0 / period as f64,
prev_close: None,
seed_buf: Vec::with_capacity(period),
avg: None,
avg: 0.0,
seeded: false,
})
}
@@ -58,7 +69,11 @@ impl Atr {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.avg
if self.seeded {
Some(self.avg)
} else {
None
}
}
}
@@ -70,17 +85,18 @@ impl Indicator for Atr {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if let Some(avg) = self.avg {
let n = self.period as f64;
let new_avg = avg.mul_add(n - 1.0, tr) / n;
self.avg = Some(new_avg);
if self.seeded {
// Wilder smoothing with the reciprocal hoisted out of the hot path.
let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
self.avg = new_avg;
return Some(new_avg);
}
self.seed_buf.push(tr);
if self.seed_buf.len() == self.period {
let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
self.avg = Some(seed);
self.avg = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -89,7 +105,8 @@ impl Indicator for Atr {
fn reset(&mut self) {
self.prev_close = None;
self.seed_buf.clear();
self.avg = None;
self.avg = 0.0;
self.seeded = false;
}
fn warmup_period(&self) -> usize {
@@ -97,7 +114,7 @@ impl Indicator for Atr {
}
fn is_ready(&self) -> bool {
self.avg.is_some()
self.seeded
}
fn name(&self) -> &'static str {