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
+31 -11
View File
@@ -25,7 +25,15 @@ use crate::traits::Indicator;
pub struct Ema {
period: usize,
alpha: f64,
state: Option<f64>,
/// `1 - alpha`, precomputed so the recurrence avoids a subtraction per tick.
/// Cached value, so the steady-state output is bit-for-bit unchanged.
one_minus_alpha: f64,
/// Latest EMA value, valid only once `seeded` is true. Stored as a bare `f64`
/// (plus the `seeded` flag) rather than `Option<f64>` so the steady-state
/// recurrence reads and writes 8 bytes with no enum-tag handling per tick.
current: f64,
/// Whether `current` holds a real value yet (warmup complete).
seeded: bool,
warmup_buf: Vec<f64>,
}
@@ -43,7 +51,9 @@ impl Ema {
Ok(Self {
period,
alpha,
state: None,
one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(period),
})
}
@@ -66,7 +76,9 @@ impl Ema {
Ok(Self {
period: 1,
alpha,
state: None,
one_minus_alpha: 1.0 - alpha,
current: 0.0,
seeded: false,
warmup_buf: Vec::with_capacity(1),
})
}
@@ -83,21 +95,28 @@ impl Ema {
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.state
if self.seeded {
Some(self.current)
} else {
None
}
}
/// Internal helper that feeds a value without finiteness validation. The caller
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
if let Some(prev) = self.state {
let new = self.alpha.mul_add(input, (1.0 - self.alpha) * prev);
self.state = Some(new);
if self.seeded {
let new = self
.alpha
.mul_add(input, self.one_minus_alpha * self.current);
self.current = new;
return Some(new);
}
self.warmup_buf.push(input);
if self.warmup_buf.len() == self.period {
let seed = self.warmup_buf.iter().copied().sum::<f64>() / self.period as f64;
self.state = Some(seed);
self.current = seed;
self.seeded = true;
return Some(seed);
}
None
@@ -110,13 +129,14 @@ impl Indicator for Ema {
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.state;
return self.value();
}
self.step_unchecked(input)
}
fn reset(&mut self) {
self.state = None;
self.current = 0.0;
self.seeded = false;
self.warmup_buf.clear();
}
@@ -125,7 +145,7 @@ impl Indicator for Ema {
}
fn is_ready(&self) -> bool {
self.state.is_some()
self.seeded
}
fn name(&self) -> &'static str {