Files
wickra/crates/wickra-core/src/indicators/stochastic.rs
T
kingchenc 3be267cb03 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.
2026-05-21 17:50:45 +02:00

316 lines
9.9 KiB
Rust

//! Stochastic Oscillator (%K and %D).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Stochastic Oscillator output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StochasticOutput {
/// Raw %K: `100 * (close - LL) / (HH - LL)` over the lookback.
pub k: f64,
/// %D: SMA of %K over the smoothing period.
pub d: f64,
}
/// Fast Stochastic Oscillator.
///
/// Maintains rolling highest-high and lowest-low over the lookback period via a
/// monotonic deque, giving O(1) amortized updates. %D is an SMA of the %K series.
#[derive(Debug, Clone)]
pub struct Stochastic {
k_period: usize,
d_period: usize,
candles: VecDeque<Candle>,
// Monotonic deques over candle indices in the rolling window.
hh_idx: VecDeque<usize>, // indices of candidates for highest high (front = current max)
ll_idx: VecDeque<usize>, // indices of candidates for lowest low (front = current min)
// Absolute count of candles ever ingested. Used so monotonic-deque indices stay unique.
count: usize,
d_sma: Sma,
last_k: Option<f64>,
}
impl Stochastic {
/// Construct a stochastic with %K lookback and %D smoothing periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero.
pub fn new(k_period: usize, d_period: usize) -> Result<Self> {
if k_period == 0 || d_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
k_period,
d_period,
candles: VecDeque::with_capacity(k_period),
hh_idx: VecDeque::with_capacity(k_period),
ll_idx: VecDeque::with_capacity(k_period),
count: 0,
d_sma: Sma::new(d_period)?,
last_k: None,
})
}
/// Classic fast stochastic: `%K = 14`, `%D = 3`.
pub fn classic() -> Self {
Self::new(14, 3).expect("classic stochastic periods are valid")
}
/// Configured `(k_period, d_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.k_period, self.d_period)
}
fn push_window(&mut self, candle: Candle) {
let idx = self.count;
self.count += 1;
// Drop deque entries that are outside the window.
let oldest_keep_idx = idx.saturating_sub(self.k_period - 1);
while let Some(&front) = self.hh_idx.front() {
if front < oldest_keep_idx {
self.hh_idx.pop_front();
} else {
break;
}
}
while let Some(&front) = self.ll_idx.front() {
if front < oldest_keep_idx {
self.ll_idx.pop_front();
} else {
break;
}
}
// Maintain monotonic-decreasing deque for highs.
while let Some(&back) = self.hh_idx.back() {
let back_off = back - idx.saturating_sub(self.candles.len());
if self.candles[back_off].high <= candle.high {
self.hh_idx.pop_back();
} else {
break;
}
}
self.hh_idx.push_back(idx);
// Maintain monotonic-increasing deque for lows.
while let Some(&back) = self.ll_idx.back() {
let back_off = back - idx.saturating_sub(self.candles.len());
if self.candles[back_off].low >= candle.low {
self.ll_idx.pop_back();
} else {
break;
}
}
self.ll_idx.push_back(idx);
if self.candles.len() == self.k_period {
self.candles.pop_front();
}
self.candles.push_back(candle);
}
fn current_extremes(&self) -> (f64, f64) {
let base = self.count - self.candles.len();
let hi = self.candles[self.hh_idx[0] - base].high;
let lo = self.candles[self.ll_idx[0] - base].low;
(hi, lo)
}
}
impl Indicator for Stochastic {
type Input = Candle;
type Output = StochasticOutput;
fn update(&mut self, candle: Candle) -> Option<StochasticOutput> {
self.push_window(candle);
if self.candles.len() < self.k_period {
return None;
}
let (hh, ll) = self.current_extremes();
let range = hh - ll;
let k = if range == 0.0 {
// Flat range; convention: 50 (neutral, like RSI on flat input).
50.0
} else {
100.0 * (candle.close - ll) / range
};
self.last_k = Some(k);
let d = self.d_sma.update(k)?;
Some(StochasticOutput { k, d })
}
fn reset(&mut self) {
self.candles.clear();
self.hh_idx.clear();
self.ll_idx.clear();
self.count = 0;
self.d_sma.reset();
self.last_k = None;
}
fn warmup_period(&self) -> usize {
self.k_period + self.d_period - 1
}
fn is_ready(&self) -> bool {
self.d_sma.is_ready()
}
fn name(&self) -> &'static str {
"Stochastic"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
/// Naive %K computation for cross-checks.
fn naive_k(candles: &[Candle], k_period: usize) -> Vec<Option<f64>> {
candles
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < k_period {
None
} else {
let w = &candles[i + 1 - k_period..=i];
let hh = w.iter().map(|x| x.high).fold(f64::NEG_INFINITY, f64::max);
let ll = w.iter().map(|x| x.low).fold(f64::INFINITY, f64::min);
let range = hh - ll;
let cl = candles[i].close;
Some(if range == 0.0 {
50.0
} else {
100.0 * (cl - ll) / range
})
}
})
.collect()
}
#[test]
fn rejects_zero_periods() {
assert!(matches!(Stochastic::new(0, 3), Err(Error::PeriodZero)));
assert!(matches!(Stochastic::new(14, 0), Err(Error::PeriodZero)));
}
#[test]
fn close_at_high_yields_k_100() {
let candles = vec![
c(10.0, 8.0, 9.0),
c(11.0, 9.0, 10.0),
c(12.0, 10.0, 12.0), // close == high == HH
];
let mut s = Stochastic::new(3, 1).unwrap();
let out = s.batch(&candles);
assert_relative_eq!(out[2].unwrap().k, 100.0, epsilon = 1e-12);
}
#[test]
fn close_at_low_yields_k_0() {
let candles = vec![
c(10.0, 8.0, 9.0),
c(11.0, 9.0, 10.0),
c(12.0, 8.0, 8.0), // close == LL
];
let mut s = Stochastic::new(3, 1).unwrap();
let out = s.batch(&candles);
assert_relative_eq!(out[2].unwrap().k, 0.0, epsilon = 1e-12);
}
#[test]
fn flat_range_yields_k_50() {
let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
let mut s = Stochastic::new(14, 3).unwrap();
for o in s.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.k, 50.0, epsilon = 1e-12);
assert_relative_eq!(o.d, 50.0, epsilon = 1e-12);
}
}
#[test]
fn k_matches_naive() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 50.0 + (f64::from(i) * 0.4).sin() * 10.0;
c(mid + 2.0, mid - 2.0, mid + (f64::from(i) * 0.7).cos())
})
.collect();
let mut s = Stochastic::new(14, 3).unwrap();
let out = s.batch(&candles);
let naive = naive_k(&candles, 14);
for (i, got) in out.iter().enumerate() {
if let Some(o) = got {
let n = naive[i].expect("naive ready");
assert_relative_eq!(o.k, n, epsilon = 1e-9);
}
}
}
#[test]
fn d_is_sma_of_k() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let mid = 50.0 + f64::from(i).sin() * 5.0;
c(mid + 1.5, mid - 1.5, mid)
})
.collect();
let mut s = Stochastic::new(14, 3).unwrap();
let out = s.batch(&candles);
// The naive %K series gives us the ground-truth values that %D should average.
let naive_ks = naive_k(&candles, 14);
// The first emitted %D corresponds to the SMA of the first three valid %K values
// (i.e. those at indices 13, 14, 15). At that point %D becomes ready, and the
// first `Some(_)` output appears at index 15.
let first_emit_idx = out
.iter()
.position(Option::is_some)
.expect("d eventually emits");
let first_d = out[first_emit_idx].unwrap().d;
let k_window = &naive_ks[first_emit_idx - 2..=first_emit_idx];
let want = k_window
.iter()
.map(|v| v.expect("naive K ready inside window"))
.sum::<f64>()
/ 3.0;
assert_relative_eq!(first_d, want, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
let mid = 100.0 + f64::from(i) * 0.5;
c(mid + 2.0, mid - 2.0, mid)
})
.collect();
let mut a = Stochastic::new(14, 3).unwrap();
let mut b = Stochastic::new(14, 3).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut s = Stochastic::new(5, 3).unwrap();
let candles: Vec<Candle> = (0..10).map(|i| c(10.0 + f64::from(i), 5.0, 7.0)).collect();
s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(candles[0]), None);
}
}