Wickra 0.1.0: streaming-first technical indicators

A multi-language technical analysis library: 25 indicators across trend,
momentum, volatility, and volume families, every one a state machine with
O(1) per-tick updates. Batch evaluation is provided by a blanket extension
trait over the streaming primitive, so live trading bots and historical
backtests run the same code path.

What ships in this initial drop:

  crates/wickra-core   - 25 indicators, Indicator/BatchExt/Chain traits,
                          OHLCV types with validation; 171 unit tests,
                          property tests, Wilder/Bollinger textbook tests.
  crates/wickra        - top-level facade + criterion benches for every
                          indicator at 1K/10K/100K series sizes.
  crates/wickra-data   - streaming CSV reader, tick-to-candle aggregator,
                          multi-timeframe resampler, Binance Spot kline
                          WebSocket adapter behind feature live-binance;
                          11 unit + 1 doctest.
  bindings/python      - PyO3 + maturin, NumPy I/O, type stubs (.pyi),
                          56 pytest tests including streaming==batch
                          equivalence, Wilder reference values, lifecycle.
  bindings/node        - napi-rs native module, TypeScript .d.ts
                          auto-generated, 7 node --test cases.
  bindings/wasm        - wasm-bindgen ES module for browser/bundler/Node;
                          interactive HTML demo at examples/index.html.
  examples/            - Python and Rust scripts: backtest, live trading,
                          parallel multi-asset, multi-timeframe, Binance.
  benchmarks/          - cross-library comparison against TA-Lib,
                          pandas-ta, finta, talipp; Wickra wins every
                          category by 11-1030x (batch) and 17x+ streaming.
  .github/workflows/   - CI matrix (Rust + Python + Node + WASM on
                          Linux/macOS/Windows), release pipeline for
                          PyPI wheels and npm.

Indicators (25):
  Trend       SMA EMA WMA DEMA TEMA HMA KAMA
  Momentum    RSI MACD Stochastic CCI ROC WilliamsR ADX MFI TRIX
              AwesomeOscillator Aroon
  Volatility  BollingerBands ATR Keltner Donchian PSAR
  Volume      OBV VWAP (cumulative + rolling)

cargo clippy --workspace --all-targets -D warnings is clean. License: Apache-2.0.
This commit is contained in:
kingchenc
2026-05-21 17:50:45 +02:00
commit 3be267cb03
81 changed files with 14453 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
//! Moving Average Convergence Divergence (MACD).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// MACD output: the three classic series at a given step.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MacdOutput {
/// Fast EMA slow EMA.
pub macd: f64,
/// EMA of `macd` over the signal period.
pub signal: f64,
/// `macd signal`.
pub histogram: f64,
}
/// MACD = EMA(fast) EMA(slow), with a signal EMA on top.
///
/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`. The signal EMA
/// is seeded from the first `signal` raw MACD values, so the first full
/// [`MacdOutput`] is emitted after `slow + signal 1` inputs (assuming the
/// slow EMA seeded by then).
#[derive(Debug, Clone)]
pub struct MacdIndicator {
fast: Ema,
slow: Ema,
signal_ema: Ema,
fast_period: usize,
slow_period: usize,
signal_period: usize,
last: Option<MacdOutput>,
}
impl MacdIndicator {
/// Construct a MACD with the given periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is zero, and
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Ema::new(fast)?,
slow: Ema::new(slow)?,
signal_ema: Ema::new(signal)?,
fast_period: fast,
slow_period: slow,
signal_period: signal,
last: None,
})
}
/// Default `(12, 26, 9)` configuration, matching every classical chart package.
pub fn classic() -> Self {
Self::new(12, 26, 9).expect("classic MACD periods are valid")
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.signal_period)
}
/// Most recent fully-computed output if available.
pub const fn value(&self) -> Option<MacdOutput> {
self.last
}
}
impl Indicator for MacdIndicator {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, input: f64) -> Option<MacdOutput> {
if !input.is_finite() {
return self.last;
}
let fast = self.fast.update(input);
let slow = self.slow.update(input);
match (fast, slow) {
(Some(f), Some(s)) => {
let macd = f - s;
let signal = self.signal_ema.update(macd)?;
let out = MacdOutput {
macd,
signal,
histogram: macd - signal,
};
self.last = Some(out);
Some(out)
}
_ => None,
}
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal_ema.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
// Slow EMA needs `slow` inputs to seed; signal EMA needs another `signal - 1`.
self.slow_period + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"MACD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_fast_geq_slow() {
assert!(matches!(
MacdIndicator::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
MacdIndicator::new(12, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(
MacdIndicator::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdIndicator::new(12, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdIndicator::new(12, 26, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut macd = MacdIndicator::classic();
let out = macd.batch(&prices);
let warmup = macd.warmup_period();
// Indices 0..warmup-1 are None, index warmup-1 might be Some or might still need
// the signal EMA's seeding. Our warmup_period is the index at which the first
// signal value appears: slow + signal - 1.
for x in out.iter().take(warmup - 1) {
assert!(x.is_none(), "expected None within warmup");
}
assert!(
out[warmup - 1].is_some(),
"expected first emission at warmup_period - 1 ({warmup} idx)"
);
}
#[test]
fn histogram_equals_macd_minus_signal() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut macd = MacdIndicator::classic();
for v in macd.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v.histogram, v.macd - v.signal, epsilon = 1e-12);
}
}
#[test]
fn constant_series_yields_zero_macd_eventually() {
let mut macd = MacdIndicator::classic();
let out = macd.batch(&[100.0_f64; 200]);
// Both EMAs converge to 100, so MACD must approach 0.
let last = out.iter().rev().flatten().next().expect("emits a value");
assert_relative_eq!(last.macd, 0.0, epsilon = 1e-9);
assert_relative_eq!(last.signal, 0.0, epsilon = 1e-9);
assert_relative_eq!(last.histogram, 0.0, epsilon = 1e-9);
}
#[test]
fn rising_series_macd_positive_then_signal_catches_up() {
let prices: Vec<f64> = (1..=200).map(f64::from).collect();
let mut macd = MacdIndicator::classic();
let out = macd.batch(&prices);
let last = out.iter().rev().flatten().next().unwrap();
assert!(last.macd > 0.0, "rising series must yield positive MACD");
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100)
.map(|i| (f64::from(i) * 0.4).cos() * 10.0)
.collect();
let mut a = MacdIndicator::classic();
let mut b = MacdIndicator::classic();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut macd = MacdIndicator::classic();
macd.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(macd.is_ready());
macd.reset();
assert!(!macd.is_ready());
assert_eq!(macd.update(1.0), None);
}
}