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:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "wickra-bench"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Internal cross-library benchmark harness (not published)."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
wickra = { path = "../wickra" }
|
||||
wickra-data = { path = "../wickra-data" }
|
||||
criterion = { workspace = true }
|
||||
kand = "0.2.2"
|
||||
ta = "0.5.0"
|
||||
yata = "0.7.0"
|
||||
|
||||
[[bench]]
|
||||
name = "cross_lib"
|
||||
harness = false
|
||||
@@ -0,0 +1,695 @@
|
||||
//! Cross-library Criterion benchmark: Wickra vs `kand` vs `ta` (ta-rs) vs `yata`.
|
||||
//!
|
||||
//! All four are pure-Rust technical-analysis crates, so this is a like-for-like
|
||||
//! Rust-vs-Rust comparison with no language-binding overhead. It feeds the exact
|
||||
//! same BTCUSDT 1-minute candle series used by `crates/wickra/benches/indicators.rs`.
|
||||
//!
|
||||
//! Two arenas, kept honest:
|
||||
//!
|
||||
//! * **Streaming** (`*/stream`): one value fed at a time. Wickra (`Indicator::update`),
|
||||
//! ta-rs (`Next::next`) and yata (`Method::next`) carry their own state; `kand`
|
||||
//! exposes stateless `*_inc` helpers, so the per-tick state is threaded manually
|
||||
//! here, seeded from `kand`'s own batch output (the seed is computed outside the
|
||||
//! timed closure). yata only appears for SMA/EMA — its RSI/MACD/Bollinger/ATR are
|
||||
//! exposed through a heavier signal-oriented indicator API, not a raw-value method,
|
||||
//! so they are intentionally left out rather than compared unfairly.
|
||||
//! * **Batch** (`*/batch`): the whole series at once. Only Wickra (`BatchExt::batch`)
|
||||
//! and `kand` (TA-Lib-style fill-the-output-slice functions) have a real batch API;
|
||||
//! ta-rs and yata are streaming-only and are deliberately absent from this arena.
|
||||
//!
|
||||
//! Run: `cargo bench -p wickra-bench`
|
||||
|
||||
// Each indicator's benchmark group spells out every library arm explicitly, which
|
||||
// runs a few groups over the 100-line lint threshold; that verbosity is the point.
|
||||
#![allow(clippy::too_many_lines)]
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::hint::black_box;
|
||||
use wickra::{Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
|
||||
use wickra_data::csv::CandleReader;
|
||||
use yata::prelude::Method;
|
||||
|
||||
const SIZES: &[usize] = &[1_000, 10_000, 50_000];
|
||||
|
||||
const SMA_PERIOD: usize = 20;
|
||||
const EMA_PERIOD: usize = 20;
|
||||
const RSI_PERIOD: usize = 14;
|
||||
const ATR_PERIOD: usize = 14;
|
||||
const BB_PERIOD: usize = 20;
|
||||
const BB_DEV: f64 = 2.0;
|
||||
const MACD_FAST: usize = 12;
|
||||
const MACD_SLOW: usize = 26;
|
||||
const MACD_SIGNAL: usize = 9;
|
||||
|
||||
fn load_candles() -> Vec<Candle> {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../examples/data/btcusdt-1m.csv"
|
||||
);
|
||||
CandleReader::open(path)
|
||||
.expect("dataset present")
|
||||
.read_all()
|
||||
.expect("valid OHLCV rows")
|
||||
}
|
||||
|
||||
/// Mean of the first `period` samples — the warmup seed for `kand`'s SMA/EMA `*_inc`.
|
||||
fn window_mean(series: &[f64], period: usize) -> f64 {
|
||||
series[..period].iter().sum::<f64>() / period as f64
|
||||
}
|
||||
|
||||
fn sma_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
let mut group = crit.benchmark_group("sma_20");
|
||||
for &len in SIZES {
|
||||
let len = len.min(closes.len());
|
||||
let series: &[f64] = &closes[..len];
|
||||
group.throughput(Throughput::Elements(len as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Sma::new(SMA_PERIOD).unwrap();
|
||||
for &price in series {
|
||||
black_box(ind.update(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Sma::new(SMA_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
let seed = window_mean(series, SMA_PERIOD);
|
||||
bencher.iter(|| {
|
||||
let mut prev = seed;
|
||||
for idx in SMA_PERIOD..series.len() {
|
||||
prev = kand::ohlcv::sma::sma_inc(
|
||||
prev,
|
||||
series[idx],
|
||||
series[idx - SMA_PERIOD],
|
||||
SMA_PERIOD,
|
||||
)
|
||||
.unwrap();
|
||||
black_box(prev);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut out = vec![0.0; series.len()];
|
||||
kand::ohlcv::sma::sma(series, SMA_PERIOD, &mut out).unwrap();
|
||||
black_box(&out);
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("ta-rs/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = ta::indicators::SimpleMovingAverage::new(SMA_PERIOD).unwrap();
|
||||
for &price in series {
|
||||
black_box(ta::Next::next(&mut ind, price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("yata/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = yata::methods::SMA::new(SMA_PERIOD as u8, &series[0]).unwrap();
|
||||
for price in series {
|
||||
black_box(ind.next(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn ema_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
let mut group = crit.benchmark_group("ema_20");
|
||||
for &len in SIZES {
|
||||
let len = len.min(closes.len());
|
||||
let series: &[f64] = &closes[..len];
|
||||
group.throughput(Throughput::Elements(len as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Ema::new(EMA_PERIOD).unwrap();
|
||||
for &price in series {
|
||||
black_box(ind.update(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Ema::new(EMA_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
let seed = window_mean(series, EMA_PERIOD);
|
||||
bencher.iter(|| {
|
||||
let mut prev = seed;
|
||||
for &price in &series[EMA_PERIOD..] {
|
||||
prev = kand::ohlcv::ema::ema_inc(price, prev, EMA_PERIOD, None).unwrap();
|
||||
black_box(prev);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut out = vec![0.0; series.len()];
|
||||
kand::ohlcv::ema::ema(series, EMA_PERIOD, None, &mut out).unwrap();
|
||||
black_box(&out);
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("ta-rs/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind =
|
||||
ta::indicators::ExponentialMovingAverage::new(EMA_PERIOD).unwrap();
|
||||
for &price in series {
|
||||
black_box(ta::Next::next(&mut ind, price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("yata/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = yata::methods::EMA::new(EMA_PERIOD as u8, &series[0]).unwrap();
|
||||
for price in series {
|
||||
black_box(ind.next(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
let mut group = crit.benchmark_group("rsi_14");
|
||||
for &len in SIZES {
|
||||
let len = len.min(closes.len());
|
||||
let series: &[f64] = &closes[..len];
|
||||
group.throughput(Throughput::Elements(len as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
|
||||
for &price in series {
|
||||
black_box(ind.update(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
// Wilder seed: simple average of the first `period` gains and losses.
|
||||
let mut gain = 0.0;
|
||||
let mut loss = 0.0;
|
||||
for idx in 1..=RSI_PERIOD {
|
||||
let delta = series[idx] - series[idx - 1];
|
||||
if delta > 0.0 {
|
||||
gain += delta;
|
||||
} else {
|
||||
loss -= delta;
|
||||
}
|
||||
}
|
||||
let seed_gain = gain / RSI_PERIOD as f64;
|
||||
let seed_loss = loss / RSI_PERIOD as f64;
|
||||
bencher.iter(|| {
|
||||
let mut avg_gain = seed_gain;
|
||||
let mut avg_loss = seed_loss;
|
||||
let mut prev_price = series[RSI_PERIOD];
|
||||
for &price in &series[RSI_PERIOD + 1..] {
|
||||
let (rsi, next_gain, next_loss) = kand::ohlcv::rsi::rsi_inc(
|
||||
price, prev_price, avg_gain, avg_loss, RSI_PERIOD,
|
||||
)
|
||||
.unwrap();
|
||||
avg_gain = next_gain;
|
||||
avg_loss = next_loss;
|
||||
prev_price = price;
|
||||
black_box(rsi);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut rsi = vec![0.0; series.len()];
|
||||
let mut avg_gain = vec![0.0; series.len()];
|
||||
let mut avg_loss = vec![0.0; series.len()];
|
||||
kand::ohlcv::rsi::rsi(
|
||||
series,
|
||||
RSI_PERIOD,
|
||||
&mut rsi,
|
||||
&mut avg_gain,
|
||||
&mut avg_loss,
|
||||
)
|
||||
.unwrap();
|
||||
black_box(&rsi);
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("ta-rs/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = ta::indicators::RelativeStrengthIndex::new(RSI_PERIOD).unwrap();
|
||||
for &price in series {
|
||||
black_box(ta::Next::next(&mut ind, price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn macd_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
let mut group = crit.benchmark_group("macd_12_26_9");
|
||||
for &len in SIZES {
|
||||
let len = len.min(closes.len());
|
||||
let series: &[f64] = &closes[..len];
|
||||
group.throughput(Throughput::Elements(len as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = MacdIndicator::classic();
|
||||
for &price in series {
|
||||
black_box(ind.update(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = MacdIndicator::classic();
|
||||
black_box(ind.batch(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
// Seed the fast/slow/signal EMAs from kand's own warmed-up batch state.
|
||||
let lookback =
|
||||
kand::ohlcv::macd::lookback(MACD_FAST, MACD_SLOW, MACD_SIGNAL).unwrap();
|
||||
let mut macd_line = vec![0.0; series.len()];
|
||||
let mut signal_line = vec![0.0; series.len()];
|
||||
let mut histogram = vec![0.0; series.len()];
|
||||
let mut fast_ema = vec![0.0; series.len()];
|
||||
let mut slow_ema = vec![0.0; series.len()];
|
||||
kand::ohlcv::macd::macd(
|
||||
series,
|
||||
MACD_FAST,
|
||||
MACD_SLOW,
|
||||
MACD_SIGNAL,
|
||||
&mut macd_line,
|
||||
&mut signal_line,
|
||||
&mut histogram,
|
||||
&mut fast_ema,
|
||||
&mut slow_ema,
|
||||
)
|
||||
.unwrap();
|
||||
let seed_fast = fast_ema[lookback];
|
||||
let seed_slow = slow_ema[lookback];
|
||||
let seed_signal = signal_line[lookback];
|
||||
bencher.iter(|| {
|
||||
// macd_inc returns (macd, signal, hist) but not the new EMAs, so the
|
||||
// fast/slow/signal state is threaded with kand's own ema_inc primitive.
|
||||
let mut prev_fast = seed_fast;
|
||||
let mut prev_slow = seed_slow;
|
||||
let mut prev_signal = seed_signal;
|
||||
for &price in &series[lookback + 1..] {
|
||||
let fast =
|
||||
kand::ohlcv::ema::ema_inc(price, prev_fast, MACD_FAST, None).unwrap();
|
||||
let slow =
|
||||
kand::ohlcv::ema::ema_inc(price, prev_slow, MACD_SLOW, None).unwrap();
|
||||
let macd = fast - slow;
|
||||
let signal =
|
||||
kand::ohlcv::ema::ema_inc(macd, prev_signal, MACD_SIGNAL, None)
|
||||
.unwrap();
|
||||
prev_fast = fast;
|
||||
prev_slow = slow;
|
||||
prev_signal = signal;
|
||||
black_box((macd, signal, macd - signal));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut macd_line = vec![0.0; series.len()];
|
||||
let mut signal_line = vec![0.0; series.len()];
|
||||
let mut histogram = vec![0.0; series.len()];
|
||||
let mut fast_ema = vec![0.0; series.len()];
|
||||
let mut slow_ema = vec![0.0; series.len()];
|
||||
kand::ohlcv::macd::macd(
|
||||
series,
|
||||
MACD_FAST,
|
||||
MACD_SLOW,
|
||||
MACD_SIGNAL,
|
||||
&mut macd_line,
|
||||
&mut signal_line,
|
||||
&mut histogram,
|
||||
&mut fast_ema,
|
||||
&mut slow_ema,
|
||||
)
|
||||
.unwrap();
|
||||
black_box(&macd_line);
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("ta-rs/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = ta::indicators::MovingAverageConvergenceDivergence::new(
|
||||
MACD_FAST,
|
||||
MACD_SLOW,
|
||||
MACD_SIGNAL,
|
||||
)
|
||||
.unwrap();
|
||||
for &price in series {
|
||||
black_box(ta::Next::next(&mut ind, price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
let mut group = crit.benchmark_group("bollinger_20_2");
|
||||
for &len in SIZES {
|
||||
let len = len.min(closes.len());
|
||||
let series: &[f64] = &closes[..len];
|
||||
group.throughput(Throughput::Elements(len as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
|
||||
for &price in series {
|
||||
black_box(ind.update(price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
// Seed running sma/sum/sum_sq from kand's batch state at the warmup edge.
|
||||
let mut upper = vec![0.0; series.len()];
|
||||
let mut middle = vec![0.0; series.len()];
|
||||
let mut lower = vec![0.0; series.len()];
|
||||
let mut sma = vec![0.0; series.len()];
|
||||
let mut variance = vec![0.0; series.len()];
|
||||
let mut sum = vec![0.0; series.len()];
|
||||
let mut sum_sq = vec![0.0; series.len()];
|
||||
kand::ohlcv::bbands::bbands(
|
||||
series,
|
||||
BB_PERIOD,
|
||||
BB_DEV,
|
||||
BB_DEV,
|
||||
&mut upper,
|
||||
&mut middle,
|
||||
&mut lower,
|
||||
&mut sma,
|
||||
&mut variance,
|
||||
&mut sum,
|
||||
&mut sum_sq,
|
||||
)
|
||||
.unwrap();
|
||||
let seed_sma = sma[BB_PERIOD - 1];
|
||||
let seed_sum = sum[BB_PERIOD - 1];
|
||||
let seed_sum_sq = sum_sq[BB_PERIOD - 1];
|
||||
bencher.iter(|| {
|
||||
let mut prev_sma = seed_sma;
|
||||
let mut prev_sum = seed_sum;
|
||||
let mut prev_sum_sq = seed_sum_sq;
|
||||
for idx in BB_PERIOD..series.len() {
|
||||
let result = kand::ohlcv::bbands::bbands_inc(
|
||||
series[idx],
|
||||
prev_sma,
|
||||
prev_sum,
|
||||
prev_sum_sq,
|
||||
series[idx - BB_PERIOD],
|
||||
BB_PERIOD,
|
||||
BB_DEV,
|
||||
BB_DEV,
|
||||
)
|
||||
.unwrap();
|
||||
prev_sma = result.1;
|
||||
prev_sum = result.4;
|
||||
prev_sum_sq = result.5;
|
||||
black_box((result.0, result.1, result.2));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut upper = vec![0.0; series.len()];
|
||||
let mut middle = vec![0.0; series.len()];
|
||||
let mut lower = vec![0.0; series.len()];
|
||||
let mut sma = vec![0.0; series.len()];
|
||||
let mut variance = vec![0.0; series.len()];
|
||||
let mut sum = vec![0.0; series.len()];
|
||||
let mut sum_sq = vec![0.0; series.len()];
|
||||
kand::ohlcv::bbands::bbands(
|
||||
series,
|
||||
BB_PERIOD,
|
||||
BB_DEV,
|
||||
BB_DEV,
|
||||
&mut upper,
|
||||
&mut middle,
|
||||
&mut lower,
|
||||
&mut sma,
|
||||
&mut variance,
|
||||
&mut sum,
|
||||
&mut sum_sq,
|
||||
)
|
||||
.unwrap();
|
||||
black_box(&upper);
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("ta-rs/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = ta::indicators::BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
|
||||
for &price in series {
|
||||
black_box(ta::Next::next(&mut ind, price));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
|
||||
let mut group = crit.benchmark_group("atr_14");
|
||||
for &len in SIZES {
|
||||
let len = len.min(candles.len());
|
||||
let series: &[Candle] = &candles[..len];
|
||||
group.throughput(Throughput::Elements(len as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Atr::new(ATR_PERIOD).unwrap();
|
||||
for &candle in series {
|
||||
black_box(ind.update(candle));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Atr::new(ATR_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
|
||||
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
|
||||
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
|
||||
// Seed prev_atr from kand's batch ATR at the first valid index (= period).
|
||||
let mut atr_out = vec![0.0; series.len()];
|
||||
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
|
||||
let seed_atr = atr_out[ATR_PERIOD];
|
||||
bencher.iter(|| {
|
||||
let mut prev_atr = seed_atr;
|
||||
for idx in ATR_PERIOD + 1..series.len() {
|
||||
prev_atr = kand::ohlcv::atr::atr_inc(
|
||||
high[idx],
|
||||
low[idx],
|
||||
close[idx - 1],
|
||||
prev_atr,
|
||||
ATR_PERIOD,
|
||||
)
|
||||
.unwrap();
|
||||
black_box(prev_atr);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("kand/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
|
||||
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
|
||||
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
|
||||
bencher.iter(|| {
|
||||
let mut atr_out = vec![0.0; series.len()];
|
||||
kand::ohlcv::atr::atr(&high, &low, &close, ATR_PERIOD, &mut atr_out).unwrap();
|
||||
black_box(&atr_out);
|
||||
});
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("ta-rs/stream", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
let items: Vec<ta::DataItem> = series
|
||||
.iter()
|
||||
.map(|candle| {
|
||||
ta::DataItem::builder()
|
||||
.open(candle.open)
|
||||
.high(candle.high)
|
||||
.low(candle.low)
|
||||
.close(candle.close)
|
||||
.volume(candle.volume)
|
||||
.build()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
bencher.iter(|| {
|
||||
let mut ind = ta::indicators::AverageTrueRange::new(ATR_PERIOD).unwrap();
|
||||
for item in &items {
|
||||
black_box(ta::Next::next(&mut ind, item));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn benches(crit: &mut Criterion) {
|
||||
let candles = load_candles();
|
||||
let closes: Vec<f64> = candles.iter().map(|candle| candle.close).collect();
|
||||
sma_group(crit, &closes);
|
||||
ema_group(crit, &closes);
|
||||
rsi_group(crit, &closes);
|
||||
macd_group(crit, &closes);
|
||||
bbands_group(crit, &closes);
|
||||
atr_group(crit, &candles);
|
||||
}
|
||||
|
||||
criterion_group!(name = cross_lib; config = Criterion::default(); targets = benches);
|
||||
criterion_main!(cross_lib);
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Internal cross-library benchmark harness for Wickra.
|
||||
//!
|
||||
//! This crate is `publish = false`. It exists only to host the Criterion
|
||||
//! benchmark in `benches/cross_lib.rs`, which compares Wickra against the
|
||||
//! Rust technical-analysis crates `kand`, `ta` (ta-rs) and `yata` on an
|
||||
//! identical candle series. It deliberately carries no library code.
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Bollinger Bands.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
@@ -49,7 +47,13 @@ pub struct BollingerOutput {
|
||||
pub struct BollingerBands {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
window: VecDeque<f64>,
|
||||
/// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
|
||||
/// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path.
|
||||
buf: Box<[f64]>,
|
||||
/// Index of the next slot to write — also the oldest element once full.
|
||||
head: usize,
|
||||
/// Number of slots filled, saturating at `period`.
|
||||
count: usize,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
/// Number of finite updates since the running sums were last reseeded
|
||||
@@ -80,7 +84,9 @@ impl BollingerBands {
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
window: VecDeque::with_capacity(period),
|
||||
buf: vec![0.0; period].into_boxed_slice(),
|
||||
head: 0,
|
||||
count: 0,
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
updates_since_recompute: 0,
|
||||
@@ -103,7 +109,7 @@ impl BollingerBands {
|
||||
}
|
||||
|
||||
fn current(&self) -> Option<BollingerOutput> {
|
||||
if self.window.len() != self.period {
|
||||
if self.count != self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
@@ -129,25 +135,38 @@ impl Indicator for BollingerBands {
|
||||
if !input.is_finite() {
|
||||
return self.current();
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
if self.count == self.period {
|
||||
let old = self.buf[self.head];
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
self.buf[self.head] = input;
|
||||
self.sum += input;
|
||||
self.sum_sq += input * input;
|
||||
} else {
|
||||
self.buf[self.head] = input;
|
||||
self.sum += input;
|
||||
self.sum_sq += input * input;
|
||||
self.count += 1;
|
||||
}
|
||||
self.head += 1;
|
||||
if self.head == self.period {
|
||||
self.head = 0;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
self.sum_sq += input * input;
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
|
||||
self.sum = self.window.iter().copied().sum();
|
||||
self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
|
||||
// Reseed in chronological order (oldest at `head`) to keep the running
|
||||
// sums bit-equivalent to a fresh from-scratch pass on stable inputs.
|
||||
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
|
||||
self.sum = chronological.clone().copied().sum();
|
||||
self.sum_sq = chronological.map(|&x| x * x).sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
self.current()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.head = 0;
|
||||
self.count = 0;
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
self.updates_since_recompute = 0;
|
||||
@@ -158,7 +177,7 @@ impl Indicator for BollingerBands {
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
self.count == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -171,6 +190,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
|
||||
assert!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,13 +25,24 @@ use crate::traits::Indicator;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rsi {
|
||||
period: usize,
|
||||
prev_close: Option<f64>,
|
||||
/// `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 (a reciprocal is hoisted out of the hot path).
|
||||
inv_period: f64,
|
||||
/// Previous close, valid once `has_prev` is set. Bare `f64` + flag instead of
|
||||
/// `Option<f64>` to avoid an enum-tag read on every tick.
|
||||
prev_close: f64,
|
||||
has_prev: bool,
|
||||
// Wilder seeds with the simple average of the first `period` gains/losses,
|
||||
// then transitions to recursive smoothing.
|
||||
seed_buf_gains: Vec<f64>,
|
||||
seed_buf_losses: Vec<f64>,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
/// Smoothed average gain / loss, valid once `avgs_seeded` is set. Bare `f64`s
|
||||
/// + flag so the hot recurrence avoids reading two `Option<f64>` tags per tick.
|
||||
avg_gain: f64,
|
||||
avg_loss: f64,
|
||||
avgs_seeded: bool,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
@@ -47,11 +58,15 @@ impl Rsi {
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev_close: None,
|
||||
n_minus_1: (period - 1) as f64,
|
||||
inv_period: 1.0 / period as f64,
|
||||
prev_close: 0.0,
|
||||
has_prev: false,
|
||||
seed_buf_gains: Vec::with_capacity(period),
|
||||
seed_buf_losses: Vec::with_capacity(period),
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
avg_gain: 0.0,
|
||||
avg_loss: 0.0,
|
||||
avgs_seeded: false,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
@@ -67,16 +82,16 @@ impl Rsi {
|
||||
}
|
||||
|
||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
if avg_loss == 0.0 {
|
||||
if avg_gain == 0.0 {
|
||||
// No movement at all -> RSI undefined; standard convention returns 50.
|
||||
50.0
|
||||
} else {
|
||||
100.0
|
||||
}
|
||||
// Algebraically `100 - 100/(1 + ag/al)` collapses to `100·ag/(ag+al)`,
|
||||
// which needs a single division instead of two and removes the separate
|
||||
// `rs` step. Edge cases stay exact: `al == 0, ag > 0` gives `100·ag/ag =
|
||||
// 100`; `ag == 0, al > 0` gives `0`; both zero (no movement) is the
|
||||
// undefined case and returns the neutral 50.
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
let rs = avg_gain / avg_loss;
|
||||
100.0 - 100.0 / (1.0 + rs)
|
||||
100.0 * avg_gain / denom
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,22 +105,25 @@ impl Indicator for Rsi {
|
||||
return self.last_value;
|
||||
}
|
||||
|
||||
let Some(prev) = self.prev_close else {
|
||||
self.prev_close = Some(input);
|
||||
if !self.has_prev {
|
||||
self.prev_close = input;
|
||||
self.has_prev = true;
|
||||
return None;
|
||||
};
|
||||
self.prev_close = Some(input);
|
||||
}
|
||||
let prev = self.prev_close;
|
||||
self.prev_close = input;
|
||||
|
||||
let diff = input - prev;
|
||||
let gain = if diff > 0.0 { diff } else { 0.0 };
|
||||
let loss = if diff < 0.0 { -diff } else { 0.0 };
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let n = self.period as f64;
|
||||
let new_ag = (ag * (n - 1.0) + gain) / n;
|
||||
let new_al = (al * (n - 1.0) + loss) / n;
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
if self.avgs_seeded {
|
||||
// Wilder smoothing `(prev·(n-1) + x) / n` with the reciprocal hoisted:
|
||||
// a fused multiply-add then a multiply by `1/n`, no per-tick division.
|
||||
let new_ag = self.avg_gain.mul_add(self.n_minus_1, gain) * self.inv_period;
|
||||
let new_al = self.avg_loss.mul_add(self.n_minus_1, loss) * self.inv_period;
|
||||
self.avg_gain = new_ag;
|
||||
self.avg_loss = new_al;
|
||||
let v = Self::rsi_from_avgs(new_ag, new_al);
|
||||
self.last_value = Some(v);
|
||||
return Some(v);
|
||||
@@ -116,8 +134,9 @@ impl Indicator for Rsi {
|
||||
if self.seed_buf_gains.len() == self.period {
|
||||
let ag = self.seed_buf_gains.iter().sum::<f64>() / self.period as f64;
|
||||
let al = self.seed_buf_losses.iter().sum::<f64>() / self.period as f64;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
self.avg_gain = ag;
|
||||
self.avg_loss = al;
|
||||
self.avgs_seeded = true;
|
||||
let v = Self::rsi_from_avgs(ag, al);
|
||||
self.last_value = Some(v);
|
||||
return Some(v);
|
||||
@@ -126,11 +145,13 @@ impl Indicator for Rsi {
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.prev_close = 0.0;
|
||||
self.has_prev = false;
|
||||
self.seed_buf_gains.clear();
|
||||
self.seed_buf_losses.clear();
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.avg_gain = 0.0;
|
||||
self.avg_loss = 0.0;
|
||||
self.avgs_seeded = false;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Simple Moving Average.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
@@ -33,7 +31,14 @@ use crate::traits::Indicator;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sma {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
/// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
|
||||
/// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path:
|
||||
/// sequential storage, branchless wraparound, no per-call bookkeeping.
|
||||
buf: Box<[f64]>,
|
||||
/// Index of the next slot to write — also the oldest element once full.
|
||||
head: usize,
|
||||
/// Number of slots filled, saturating at `period`.
|
||||
count: usize,
|
||||
sum: f64,
|
||||
/// Number of finite updates since the running `sum` was last reseeded from
|
||||
/// the live window. Caps accumulated floating-point drift on long streams.
|
||||
@@ -60,7 +65,9 @@ impl Sma {
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
buf: vec![0.0; period].into_boxed_slice(),
|
||||
head: 0,
|
||||
count: 0,
|
||||
sum: 0.0,
|
||||
updates_since_recompute: 0,
|
||||
})
|
||||
@@ -73,7 +80,7 @@ impl Sma {
|
||||
|
||||
/// Current value if available.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
if self.count == self.period {
|
||||
Some(self.sum / self.period as f64)
|
||||
} else {
|
||||
None
|
||||
@@ -89,25 +96,40 @@ impl Indicator for Sma {
|
||||
if !input.is_finite() {
|
||||
return self.value();
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
// Slide: drop the oldest, then add the new. Each step is a single
|
||||
// f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise.
|
||||
// The periodic reseed below caps the accumulated drift.
|
||||
let old = self.window.pop_front().expect("window non-empty");
|
||||
self.sum -= old;
|
||||
if self.count == self.period {
|
||||
// Window full: overwrite the oldest slot (at `head`). Each step is a
|
||||
// single f64 add/subtract — O(1) but introduces ~1 ULP of rounding
|
||||
// noise. The periodic reseed below caps the accumulated drift.
|
||||
self.sum -= self.buf[self.head];
|
||||
self.buf[self.head] = input;
|
||||
self.sum += input;
|
||||
} else {
|
||||
self.buf[self.head] = input;
|
||||
self.sum += input;
|
||||
self.count += 1;
|
||||
}
|
||||
// Branchless-ish wraparound, cheaper than `% period`.
|
||||
self.head += 1;
|
||||
if self.head == self.period {
|
||||
self.head = 0;
|
||||
}
|
||||
self.window.push_back(input);
|
||||
self.sum += input;
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
|
||||
self.sum = self.window.iter().copied().sum();
|
||||
// Reseed in chronological order (oldest at `head`) so the running sum
|
||||
// tracks a fresh from-scratch mean to the bit on stable inputs.
|
||||
self.sum = self.buf[self.head..]
|
||||
.iter()
|
||||
.chain(&self.buf[..self.head])
|
||||
.copied()
|
||||
.sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.head = 0;
|
||||
self.count = 0;
|
||||
self.sum = 0.0;
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
@@ -117,7 +139,7 @@ impl Indicator for Sma {
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
self.count == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -130,6 +152,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
|
||||
Reference in New Issue
Block a user