* feat(rvi): add Relative Vigor Index
Dorsey's RVI = SMA(close - open, period) / SMA(high - low, period) over
a rolling window of period candles. Candle input, single parameter
period (default 10). Positive on average-bullish windows, negative on
average-bearish. Holds the previous value if the entire window has
zero range (denominator undefined).
Reference: Donald Dorsey, also pandas-ta rvi.
Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values reference,
RviNode (4-column OHLC batch) + index.d.ts/index.js + indicators.test
.js factory + reference, WasmRvi + make_candle_ohlc helper, candle-fuzz
target + criterion bench, README + CHANGELOG.
* feat(pgo): add Pretty Good Oscillator
Mark Johnson's PGO = (close - SMA(close, period)) / EMA(TR, period).
Counts roughly how many ATR-equivalents the close sits from its
period-bar mean. Candle input, single parameter period (default 14).
Johnson's heuristic uses +3/-3 crossings as entry signals.
Touchpoints: pgo.rs + mod.rs + lib.rs re-export, PyPgo + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-close
reference, PgoNode (h/l/c) + index.d.ts/index.js + indicators.test.js
factory + reference, WasmPgo, candle-fuzz target + bench, README +
CHANGELOG.
* feat(kst): add Know Sure Thing (Pring)
Pring's long-horizon momentum oscillator: weighted sum of four
SMA-smoothed ROC series with fixed weights 1, 2, 3, 4, plus an SMA
signal line. Nine parameters (four ROC periods, four SMA periods, one
signal period); classic() applies Pring's recommended defaults.
Multi-output indicator emitting KstOutput { kst, signal }.
Touchpoints: kst.rs + mod.rs + lib.rs re-export, PyKst + __init__.py
+ test_new_indicators MULTI + test_known_values flat-input reference,
KstNode + KstValue + index.d.ts/index.js + indicators.test.js multi
factory + reference, WasmKst (manual JsValue object), scalar-fuzz
target (handled outside the f64-output drive helper), README +
CHANGELOG.
* feat(smi): add Stochastic Momentum Index (Blau)
Blau's doubly-EMA-smoothed bounded oscillator: measures the close's
displacement from the centre of the recent high-low range, scaled by
the smoothed range. Candle input, three parameters (period, d_period,
d2_period) with defaults 5 / 3 / 3.
Internally feeds both the displacement-EMA stack and the range-EMA
stack on every candle so they warm up in parallel (gating either
behind the other starves the second by one input).
Touchpoints: smi.rs + mod.rs + lib.rs re-export, PySmi + __init__.py
+ test_new_indicators CANDLE_SCALAR + test_known_values flat-input
reference, SmiNode + index.d.ts/index.js + indicators.test.js factory
+ reference, WasmSmi, candle-fuzz target, README + CHANGELOG.
* feat(laguerre-rsi): add Ehlers Laguerre RSI
Four-stage Laguerre polynomial filter wrapped in an RSI-style up/down
accumulator. Single gamma in [0, 1] (default 0.5) trades lag for
smoothness. State is seeded by setting all four L_i to the first input
so a constant series stays at the neutral 50. Output clamped to
[0, 100] to absorb floating-point rounding.
Reference: Ehlers, Time Warp - Without Space Travel, 2002.
Touchpoints: laguerre_rsi.rs + mod.rs + lib.rs re-export, PyLaguerreRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, LaguerreRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmLaguerreRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(connors-rsi): add Connors RSI (CRSI)
Larry Connors' 3-component aggregate: RSI(close), RSI(streak), and
PercentRank of the 1-period return over the last period_rank returns.
Each component is bounded in [0, 100] so the aggregate is too.
Three parameters (period_rsi, period_streak, period_rank) with
defaults 3 / 2 / 100. Streak tracks consecutive up/down runs (resets
to 0 on unchanged close).
Touchpoints: connors_rsi.rs + mod.rs + lib.rs re-export, PyConnorsRsi
+ __init__.py + test_new_indicators SCALAR + test_known_values bounded
reference, ConnorsRsiNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmConnorsRsi via scalar macro, scalar-fuzz
target, README + CHANGELOG.
* feat(inertia): add Dorsey Inertia (RVI + LinReg)
Donald Dorsey's Inertia — a LinearRegression smoothing of the RVI
series. Endpoint of an n-bar least-squares fit of RVI is the indicator
reading. Preserves trend direction while damping the ratio. Candle
input, two parameters (rvi_period, linreg_period) with defaults 14 / 20.
Touchpoints: inertia.rs + mod.rs + lib.rs re-export, PyInertia +
__init__.py + test_new_indicators CANDLE_SCALAR + test_known_values
constant reference, InertiaNode (4-column OHLC batch) + index.d.ts /
index.js + indicators.test.js factory + reference, WasmInertia,
candle-fuzz target, README + CHANGELOG.
* test(kst): Move KST out of MULTI dict (it is scalar-input)
KST sits in the MULTI dict (candle-input, multi-output) but its
update() takes a single f64, not a candle tuple. The shared streaming
loop in test_multi_streaming_matches_batch fed the OHLCV tuple in,
which crashed with `TypeError: argument 'value': must be real number,
not tuple` on every Python matrix entry.
Split into a new MULTI_SCALAR_INPUT dict with its own test function
that feeds the close-price stream as floats. KST is currently the
only such indicator; structure is ready for future scalar-input
multi-output additions (e.g. some MACD-shaped indicators).
* test(coverage): Cover SMI zero-range and ConnorsRsi zero-prev cold paths
codecov/patch on PR 40 flagged two uncovered defensive branches:
- SMI returns self.current early when the smoothed range collapses to
zero (`r2 <= 0.0`) so the formula stays defined. Exercised by feeding
bars where high == low.
- ConnorsRsi skips the ROC ring-buffer update when the previous price
is exactly zero so the divide-by-zero in `(input - prev) / prev` is
impossible. Exercised by seeding the first bar at 0.0.
160 lines
5.5 KiB
Rust
160 lines
5.5 KiB
Rust
//! Microbenchmarks for every built-in indicator.
|
|
//!
|
|
//! Run with:
|
|
//! ```text
|
|
//! cargo bench -p wickra
|
|
//! ```
|
|
//!
|
|
//! Each benchmark feeds real BTCUSDT 1-minute candles — read from the
|
|
//! checked-in dataset at the workspace `examples/data/btcusdt-1m.csv` —
|
|
//! through both the streaming (`update` loop) and batch APIs of an
|
|
//! indicator. Sizes cover small (1 000), medium (10 000), and large
|
|
//! (50 000) workloads, taken as prefixes of that dataset.
|
|
//!
|
|
//! Regenerate the dataset with:
|
|
//! ```text
|
|
//! cargo run -p wickra-examples --bin fetch_btcusdt
|
|
//! ```
|
|
|
|
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
|
use std::hint::black_box;
|
|
use wickra::{
|
|
Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, Indicator, Jma, MacdIndicator,
|
|
McGinleyDynamic, Obv, Pgo, Rsi, Rvi, Sma, Stochastic, Vidya, Wma,
|
|
};
|
|
use wickra_data::csv::CandleReader;
|
|
|
|
/// Workload sizes, in candles. Each is taken as a prefix of the dataset.
|
|
const SIZES: &[usize] = &[1_000, 10_000, 50_000];
|
|
|
|
/// Load the checked-in BTCUSDT 1-minute candle dataset from the workspace
|
|
/// `examples/data/` directory.
|
|
fn load_candles() -> Vec<Candle> {
|
|
let path = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../examples/data/btcusdt-1m.csv"
|
|
);
|
|
let mut reader = CandleReader::open(path).unwrap_or_else(|e| {
|
|
panic!(
|
|
"could not open the benchmark dataset {path}: {e}\n\
|
|
generate it with `cargo run -p wickra-examples --bin fetch_btcusdt`"
|
|
)
|
|
});
|
|
reader
|
|
.read_all()
|
|
.expect("the benchmark dataset is valid OHLCV")
|
|
}
|
|
|
|
fn bench_scalar<I, F>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
|
|
where
|
|
F: Fn() -> I,
|
|
I: Indicator<Input = f64, Output = f64> + BatchExt,
|
|
{
|
|
let mut group = c.benchmark_group(name);
|
|
for &n in SIZES {
|
|
let n = n.min(prices.len());
|
|
let series = &prices[..n];
|
|
group.throughput(Throughput::Elements(n as u64));
|
|
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| {
|
|
b.iter(|| {
|
|
let mut ind = make();
|
|
for p in prices {
|
|
black_box(ind.update(*p));
|
|
}
|
|
});
|
|
});
|
|
group.bench_with_input(BenchmarkId::new("batch", n), series, |b, prices| {
|
|
b.iter(|| {
|
|
let mut ind = make();
|
|
black_box(ind.batch(prices));
|
|
});
|
|
});
|
|
}
|
|
group.finish();
|
|
}
|
|
|
|
fn bench_macd(c: &mut Criterion, prices: &[f64]) {
|
|
let mut group = c.benchmark_group("macd");
|
|
for &n in SIZES {
|
|
let n = n.min(prices.len());
|
|
let series = &prices[..n];
|
|
group.throughput(Throughput::Elements(n as u64));
|
|
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| {
|
|
b.iter(|| {
|
|
let mut ind = MacdIndicator::classic();
|
|
for p in prices {
|
|
black_box(ind.update(*p));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
group.finish();
|
|
}
|
|
|
|
fn bench_bollinger(c: &mut Criterion, prices: &[f64]) {
|
|
let mut group = c.benchmark_group("bollinger");
|
|
for &n in SIZES {
|
|
let n = n.min(prices.len());
|
|
let series = &prices[..n];
|
|
group.throughput(Throughput::Elements(n as u64));
|
|
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| {
|
|
b.iter(|| {
|
|
let mut ind = BollingerBands::classic();
|
|
for p in prices {
|
|
black_box(ind.update(*p));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
group.finish();
|
|
}
|
|
|
|
fn bench_candle_input<I, F, O>(c: &mut Criterion, name: &str, candles: &[Candle], make: F)
|
|
where
|
|
F: Fn() -> I,
|
|
I: Indicator<Input = Candle, Output = O>,
|
|
{
|
|
let mut group = c.benchmark_group(name);
|
|
for &n in SIZES {
|
|
let n = n.min(candles.len());
|
|
let series = &candles[..n];
|
|
group.throughput(Throughput::Elements(n as u64));
|
|
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, candles| {
|
|
b.iter(|| {
|
|
let mut ind = make();
|
|
for c in candles {
|
|
black_box(ind.update(*c));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
group.finish();
|
|
}
|
|
|
|
fn benches(c: &mut Criterion) {
|
|
let candles = load_candles();
|
|
let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
|
|
|
|
bench_scalar(c, "sma", &closes, || Sma::new(14).unwrap());
|
|
bench_scalar(c, "ema", &closes, || Ema::new(14).unwrap());
|
|
bench_scalar(c, "wma", &closes, || Wma::new(14).unwrap());
|
|
bench_scalar(c, "rsi", &closes, || Rsi::new(14).unwrap());
|
|
bench_scalar(c, "alma", &closes, || Alma::new(9, 0.85, 6.0).unwrap());
|
|
bench_scalar(c, "mcginley_dynamic", &closes, || {
|
|
McGinleyDynamic::new(10).unwrap()
|
|
});
|
|
bench_scalar(c, "frama", &closes, || Frama::new(16).unwrap());
|
|
bench_scalar(c, "vidya", &closes, || Vidya::new(14, 9).unwrap());
|
|
bench_scalar(c, "jma", &closes, || Jma::new(14, 0.0, 2).unwrap());
|
|
bench_macd(c, &closes);
|
|
bench_bollinger(c, &closes);
|
|
bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap());
|
|
bench_candle_input(c, "stochastic", &candles, Stochastic::classic);
|
|
bench_candle_input(c, "obv", &candles, Obv::new);
|
|
bench_candle_input(c, "rvi", &candles, || Rvi::new(10).unwrap());
|
|
bench_candle_input(c, "pgo", &candles, || Pgo::new(14).unwrap());
|
|
}
|
|
|
|
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
|
|
criterion_main!(wickra_benches);
|