Files
wickra/crates/wickra-core/src/ohlcv.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

286 lines
8.0 KiB
Rust

//! OHLCV value types: candles and ticks.
use crate::error::{Error, Result};
/// A single OHLCV bar.
///
/// Timestamps are unitless `i64` values so callers can use whatever epoch resolution
/// they prefer (milliseconds, microseconds, seconds…). Wickra never inspects them
/// numerically beyond passing them through.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Candle {
/// Bar open price.
pub open: f64,
/// Bar high price.
pub high: f64,
/// Bar low price.
pub low: f64,
/// Bar close price.
pub close: f64,
/// Bar volume.
pub volume: f64,
/// Bar timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl Candle {
/// Construct a new candle, validating the OHLC relationships and finiteness.
///
/// # Errors
///
/// Returns [`Error::InvalidCandle`] if any of these invariants are violated:
/// - `high >= max(open, close, low)`
/// - `low <= min(open, close, high)`
/// - all of `open`, `high`, `low`, `close`, `volume` are finite
/// - `volume >= 0`
pub fn new(
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<Self> {
if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) {
return Err(Error::InvalidCandle {
message: "open, high, low, close must all be finite",
});
}
if !volume.is_finite() {
return Err(Error::InvalidCandle {
message: "volume must be finite",
});
}
if volume < 0.0 {
return Err(Error::InvalidCandle {
message: "volume must be non-negative",
});
}
if high < low {
return Err(Error::InvalidCandle {
message: "high must be >= low",
});
}
if high < open || high < close {
return Err(Error::InvalidCandle {
message: "high must be >= open and >= close",
});
}
if low > open || low > close {
return Err(Error::InvalidCandle {
message: "low must be <= open and <= close",
});
}
Ok(Self {
open,
high,
low,
close,
volume,
timestamp,
})
}
/// Construct a candle without validation. The caller asserts that all OHLC
/// invariants hold and that no field is NaN or infinite.
pub const fn new_unchecked(
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Self {
Self {
open,
high,
low,
close,
volume,
timestamp,
}
}
/// The typical price `(high + low + close) / 3`. Used by CCI, MFI, VWAP, etc.
#[inline]
pub fn typical_price(&self) -> f64 {
(self.high + self.low + self.close) / 3.0
}
/// The mid price `(high + low) / 2`.
#[inline]
pub fn median_price(&self) -> f64 {
(self.high + self.low) / 2.0
}
/// The weighted close `(high + low + 2*close) / 4`.
#[inline]
pub fn weighted_close(&self) -> f64 {
(self.high + self.low + 2.0 * self.close) / 4.0
}
/// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
/// If no previous close is supplied, falls back to `high - low`.
#[inline]
pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
let hl = self.high - self.low;
match prev_close {
Some(prev) => {
let hp = (self.high - prev).abs();
let lp = (self.low - prev).abs();
hl.max(hp).max(lp)
}
None => hl,
}
}
}
/// A single trade tick.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tick {
/// Trade price.
pub price: f64,
/// Trade size.
pub volume: f64,
/// Trade timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl Tick {
/// Construct a new tick, validating finiteness and non-negativity of volume.
///
/// # Errors
///
/// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite,
/// or [`Error::InvalidCandle`] for `volume < 0`.
pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
if !price.is_finite() || !volume.is_finite() {
return Err(Error::NonFiniteInput);
}
if volume < 0.0 {
return Err(Error::InvalidCandle {
message: "tick volume must be non-negative",
});
}
Ok(Self {
price,
volume,
timestamp,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candle_new_accepts_valid_ohlc() {
let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
assert_eq!(c.open, 10.0);
assert_eq!(c.high, 11.0);
assert_eq!(c.low, 9.0);
assert_eq!(c.close, 10.5);
assert_eq!(c.volume, 100.0);
assert_eq!(c.timestamp, 1);
}
#[test]
fn candle_new_rejects_high_below_low() {
let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_high_below_close() {
let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_low_above_open() {
let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_negative_volume() {
let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_nan_price() {
let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_typical_price() {
let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
}
#[test]
fn candle_median_price() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.median_price(), 10.0);
}
#[test]
fn candle_weighted_close() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
}
#[test]
fn candle_true_range_without_prev() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(None), 4.0);
}
#[test]
fn candle_true_range_with_gap_up() {
// Previous close 6, today's range 8-12: gap covered by |H-prev|=6
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(Some(6.0)), 6.0);
}
#[test]
fn candle_true_range_with_gap_down() {
// Previous close 14, today's range 8-12: gap covered by |L-prev|=6
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(Some(14.0)), 6.0);
}
#[test]
fn tick_new_accepts_valid() {
let t = Tick::new(100.5, 0.5, 42).unwrap();
assert_eq!(t.price, 100.5);
assert_eq!(t.volume, 0.5);
assert_eq!(t.timestamp, 42);
}
#[test]
fn tick_new_rejects_nan() {
assert!(matches!(
Tick::new(f64::NAN, 1.0, 0),
Err(Error::NonFiniteInput)
));
}
#[test]
fn tick_new_rejects_inf() {
assert!(matches!(
Tick::new(f64::INFINITY, 1.0, 0),
Err(Error::NonFiniteInput)
));
}
#[test]
fn tick_new_rejects_negative_volume() {
let err = Tick::new(100.0, -1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
}