first commit

This commit is contained in:
2026-07-12 04:18:40 +08:00
commit 0d735e43c2
555 changed files with 131932 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "wickra"
description = "Streaming-first technical analysis library: incremental indicators, drop-in TA-Lib replacement, multi-language."
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
documentation = "https://docs.wickra.org"
# Render the docs on docs.rs with every feature enabled.
[package.metadata.docs.rs]
all-features = true
[lints]
workspace = true
[dependencies]
wickra-core = { workspace = true }
[features]
default = ["parallel"]
parallel = ["wickra-core/parallel"]
[dev-dependencies]
approx = { workspace = true }
criterion = { workspace = true }
proptest = { workspace = true }
wickra-data = { path = "../wickra-data" }
[[bench]]
name = "indicators"
harness = false
[[bench]]
name = "data_layer"
harness = false
+119
View File
@@ -0,0 +1,119 @@
//! Throughput microbenchmarks for the native data layer: CSV parsing, tick
//! aggregation, and resampling.
//!
//! Run with:
//! ```text
//! cargo bench -p wickra --bench data_layer
//! ```
//!
//! These exercise `wickra-data` — the same native code every binding rides
//! through the FFI boundary characterised in `BENCHMARKS.md` §3. It is what
//! replaces `pandas` / `csv-parse` / manual tick bucketing / `pandas.resample`
//! in the nine non-Rust languages, so the numbers here are the upper bound a
//! binding can reach for "load a CSV, roll ticks into candles, resample a
//! series" without pulling in a single third-party package.
//!
//! The dataset is the checked-in `examples/data/btcusdt-1m.csv` (50 000 real
//! BTCUSDT one-minute candles). Regenerate it with
//! `cargo run -p wickra-examples --bin fetch_btcusdt`.
use criterion::{criterion_group, criterion_main, Criterion, Throughput};
use std::hint::black_box;
use wickra::{Candle, Tick};
use wickra_data::{
aggregator::{TickAggregator, Timeframe},
csv::CandleReader,
resample::Resampler,
};
const DATASET: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/data/btcusdt-1m.csv"
);
const ONE_MINUTE_MS: i64 = 60_000;
fn dataset_bytes() -> Vec<u8> {
std::fs::read(DATASET).unwrap_or_else(|e| {
panic!(
"could not read the benchmark dataset {DATASET}: {e}\n\
generate it with `cargo run -p wickra-examples --bin fetch_btcusdt`"
)
})
}
fn load_candles() -> Vec<Candle> {
CandleReader::from_reader(dataset_bytes().as_slice())
.unwrap()
.read_all()
.unwrap()
}
/// CSV bytes -> `Vec<Candle>`. Throughput is candles (rows) parsed per second.
fn bench_csv_read(c: &mut Criterion) {
let bytes = dataset_bytes();
let rows = load_candles().len() as u64;
let mut group = c.benchmark_group("data_layer/csv_read");
group.throughput(Throughput::Elements(rows));
group.bench_function("btcusdt_1m", |b| {
b.iter(|| {
let candles = CandleReader::from_reader(black_box(bytes.as_slice()))
.unwrap()
.read_all()
.unwrap();
black_box(candles.len())
});
});
group.finish();
}
/// Ticks -> one-minute candles. Throughput is ticks aggregated per second.
fn bench_tick_aggregate(c: &mut Criterion) {
let ticks: Vec<Tick> = load_candles()
.iter()
.map(|candle| Tick::new(candle.close, candle.volume, candle.timestamp).unwrap())
.collect();
let mut group = c.benchmark_group("data_layer/tick_aggregate");
group.throughput(Throughput::Elements(ticks.len() as u64));
group.bench_function("1m_buckets", |b| {
b.iter(|| {
let mut agg = TickAggregator::new(Timeframe::millis(ONE_MINUTE_MS).unwrap());
let mut emitted = 0usize;
for tick in &ticks {
emitted += agg.push(black_box(*tick)).unwrap().len();
}
black_box(emitted)
});
});
group.finish();
}
/// One-minute candles -> five-minute candles. Throughput is input candles per second.
fn bench_resample(c: &mut Criterion) {
let candles = load_candles();
let mut group = c.benchmark_group("data_layer/resample");
group.throughput(Throughput::Elements(candles.len() as u64));
group.bench_function("1m_to_5m", |b| {
b.iter(|| {
let mut resampler = Resampler::new(Timeframe::millis(5 * ONE_MINUTE_MS).unwrap());
let mut emitted = 0usize;
for candle in &candles {
if resampler.push(black_box(*candle)).unwrap().is_some() {
emitted += 1;
}
}
if resampler.flush().unwrap().is_some() {
emitted += 1;
}
black_box(emitted)
});
});
group.finish();
}
criterion_group!(
benches,
bench_csv_read,
bench_tick_aggregate,
bench_resample
);
criterion_main!(benches);
+455
View File
@@ -0,0 +1,455 @@
//! Microbenchmarks for a curated subset of the indicator catalogue.
//!
//! 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.
//!
//! ## Why curated rather than exhaustive
//!
//! The indicator catalogue has 214 entries; benching every single one
//! at three sizes inflates `cargo bench` to >10 minutes for diminishing
//! signal. The selection below picks the cheapest baseline and the
//! most-expensive representative in each family — a regression in any
//! of those is the meaningful signal; per-family redundancy benches
//! mostly produce noise.
//!
//! If you need a benchmark for a specific indicator that is not in this
//! list, add it locally and run `cargo bench -- <name>` to target just
//! that bench.
//!
//! 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::{
Adx, AnchoredRsi, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio,
Candle, Cci, ClassicPivots, ConnorsRsi, DepthSlope, DerivativesTick, EffectiveSpread, Ema,
EmpiricalModeDecomposition, Engulfing, Frama, FundingRate, FundingRateZScore,
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, KylesLambda,
Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice,
Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar,
RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput,
TdSequential, TdSequentialOutput, TpoProfile, TpoProfileOutput, Trade, TradeImbalance,
TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk,
VolumeProfile, VolumeProfileOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend,
YangZhangVolatility, T3,
};
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_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 bench_orderbook_input<I, F, O>(c: &mut Criterion, name: &str, books: &[OrderBook], make: F)
where
F: Fn() -> I,
I: Indicator<Input = OrderBook, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(books.len());
let series = &books[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, books| {
b.iter(|| {
let mut ind = make();
for book in books {
black_box(ind.update(book.clone()));
}
});
});
}
group.finish();
}
fn bench_trade_input<I, F, O>(c: &mut Criterion, name: &str, trades: &[Trade], make: F)
where
F: Fn() -> I,
I: Indicator<Input = Trade, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(trades.len());
let series = &trades[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, trades| {
b.iter(|| {
let mut ind = make();
for t in trades {
black_box(ind.update(*t));
}
});
});
}
group.finish();
}
fn bench_tradequote_input<I, F, O>(c: &mut Criterion, name: &str, quotes: &[TradeQuote], make: F)
where
F: Fn() -> I,
I: Indicator<Input = TradeQuote, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(quotes.len());
let series = &quotes[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, quotes| {
b.iter(|| {
let mut ind = make();
for q in quotes {
black_box(ind.update(*q));
}
});
});
}
group.finish();
}
fn bench_derivatives_input<I, F, O>(
c: &mut Criterion,
name: &str,
ticks: &[DerivativesTick],
make: F,
) where
F: Fn() -> I,
I: Indicator<Input = DerivativesTick, Output = O>,
{
let mut group = c.benchmark_group(name);
for &n in SIZES {
let n = n.min(ticks.len());
let series = &ticks[..n];
group.throughput(Throughput::Elements(n as u64));
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, ticks| {
b.iter(|| {
let mut ind = make();
for tick in ticks {
black_box(ind.update(*tick));
}
});
});
}
group.finish();
}
fn bench_scalar_multi<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
where
F: Fn() -> I,
I: Indicator<Input = f64, Output = O>,
{
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.finish();
}
#[allow(clippy::too_many_lines)]
fn benches(c: &mut Criterion) {
let candles = load_candles();
let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
// === Family 01 — Moving Averages ===
// Sma: cheapest baseline; Ema: recursive baseline; Frama / Jma / T3: adaptive / expensive.
bench_scalar(c, "sma", &closes, || Sma::new(14).unwrap());
bench_scalar(c, "ema", &closes, || Ema::new(14).unwrap());
bench_scalar(c, "frama", &closes, || Frama::new(16).unwrap());
bench_scalar(c, "jma", &closes, || Jma::new(14, 0.0, 2).unwrap());
bench_scalar(c, "t3", &closes, || T3::new(14, 0.7).unwrap());
// === Family 02 — Momentum Oscillators ===
// Rsi: textbook baseline; ConnorsRsi: three-component composite.
bench_scalar(c, "rsi", &closes, || Rsi::new(14).unwrap());
bench_scalar(c, "anchored_rsi", &closes, AnchoredRsi::new);
bench_candle_input(c, "cci", &candles, || Cci::new(20).unwrap());
bench_scalar(c, "connors_rsi", &closes, ConnorsRsi::classic);
// === Family 03 — Trend & Directional ===
// Adx is multi-component (DI+/DI-/ADX); WaveTrend is the heaviest in this group.
bench_candle_input(c, "adx", &candles, || Adx::new(14).unwrap());
bench_candle_input(c, "wave_trend", &candles, || WaveTrend::classic().unwrap());
// === Family 04 — Price Oscillators ===
// Macd: multi-output baseline; Stc: deeply recursive (most expensive in family).
bench_scalar_multi::<_, _, MacdOutput>(c, "macd", &closes, MacdIndicator::classic);
bench_scalar(c, "ppo", &closes, || Ppo::new(12, 26).unwrap());
bench_scalar(c, "stc", &closes, Stc::classic);
// === Family 05 — Volatility & Bands ===
// Atr: cheap baseline; Bollinger: stddev-heavy; YangZhang: most-expensive volatility metric.
bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap());
bench_scalar_multi::<_, _, BollingerOutput>(c, "bollinger", &closes, || {
BollingerBands::new(20, 2.0).unwrap()
});
bench_candle_input(c, "parkinson", &candles, || {
ParkinsonVolatility::new(20, 252).unwrap()
});
bench_candle_input(c, "yang_zhang", &candles, || {
YangZhangVolatility::new(20, 252).unwrap()
});
// === Family 06 — Bands & Channels ===
// TtmSqueeze: multi-indicator composite; VwapStdDevBands: volume-weighted.
bench_candle_input::<_, _, TtmSqueezeOutput>(c, "ttm_squeeze", &candles, || {
TtmSqueeze::new(20, 2.0, 1.5).unwrap()
});
bench_candle_input::<_, _, VwapStdDevBandsOutput>(c, "vwap_stddev_bands", &candles, || {
VwapStdDevBands::new(2.0).unwrap()
});
// === Family 07 — Trailing Stops ===
// Psar: textbook trailing stop; SuperTrend: ATR-anchored band.
bench_candle_input(c, "psar", &candles, || Psar::new(0.02, 0.02, 0.2).unwrap());
bench_candle_input::<_, _, SuperTrendOutput>(c, "super_trend", &candles, || {
SuperTrend::new(10, 3.0).unwrap()
});
// === Family 08 — Volume ===
// Obv: simplest volume cumul; Vwap: session cumul; RollingVwap: rolling window.
bench_candle_input(c, "obv", &candles, Obv::new);
bench_candle_input(c, "vwap", &candles, Vwap::new);
bench_candle_input(c, "rolling_vwap", &candles, || {
RollingVwap::new(20).unwrap()
});
// === Family 09 — Price Statistics ===
// LinearRegression: OLS baseline; HurstExponent: R/S analysis (most expensive in family);
// Autocorrelation: lag-correlation.
bench_scalar(c, "linear_regression", &closes, || {
LinearRegression::new(14).unwrap()
});
bench_scalar(c, "hurst_exponent", &closes, || {
HurstExponent::new(100, 4).unwrap()
});
bench_scalar(c, "autocorrelation", &closes, || {
Autocorrelation::new(20, 1).unwrap()
});
// === Family 10 — Ehlers / Cycle (DSP) ===
// Mama: paired adaptive MA (multi-output); HilbertDominantCycle: cycle estimation;
// EmpiricalModeDecomposition: heaviest DSP indicator in the catalogue.
bench_scalar_multi::<_, _, MamaOutput>(c, "mama", &closes, Mama::classic);
bench_scalar(
c,
"hilbert_dominant_cycle",
&closes,
HilbertDominantCycle::new,
);
bench_scalar(c, "empirical_mode_decomposition", &closes, || {
EmpiricalModeDecomposition::new(20, 0.5).unwrap()
});
// === Family 11 — Pivots & Support/Resistance ===
bench_candle_input(c, "classic_pivots", &candles, ClassicPivots::new);
// === Family 12 — DeMark ===
// TdSequential is the most complex in the family (state machine + countdown).
bench_candle_input::<_, _, TdSequentialOutput>(
c,
"td_sequential",
&candles,
TdSequential::classic,
);
// === Family 13 — Ichimoku & Charts ===
bench_candle_input::<_, _, IchimokuOutput>(c, "ichimoku", &candles, Ichimoku::classic);
// === Family 14 — Candlestick Patterns ===
// Engulfing is two-bar so representative across the candlestick family.
bench_candle_input(c, "engulfing", &candles, Engulfing::new);
// === Family 15 — Market Profile ===
bench_candle_input::<_, _, ValueAreaOutput>(c, "value_area", &candles, || {
ValueArea::new(20, 50, 0.70).unwrap()
});
bench_candle_input::<_, _, VolumeProfileOutput>(c, "volume_profile", &candles, || {
VolumeProfile::new(20, 50).unwrap()
});
bench_candle_input::<_, _, TpoProfileOutput>(c, "tpo_profile", &candles, || {
TpoProfile::new(20, 50).unwrap()
});
// === Family 16 — Risk / Performance Metrics ===
// Close-prices stand in for the equity curve / return stream; absolute
// numbers aren't meaningful here — what matters is the per-update cost.
bench_scalar(c, "sharpe_ratio", &closes, || {
SharpeRatio::new(20, 0.0).unwrap()
});
bench_scalar(c, "max_drawdown", &closes, || MaxDrawdown::new(20).unwrap());
bench_scalar(c, "calmar_ratio", &closes, || CalmarRatio::new(20).unwrap());
bench_scalar(c, "value_at_risk", &closes, || {
ValueAtRisk::new(50, 0.95).unwrap()
});
// === Family — Microstructure ===
// No order-book dataset ships with the repo, so synthesise a five-level
// book around each candle close. Benches the cheapest (top-of-book) and the
// most-expensive (full-depth sum) representatives of the family.
let books: Vec<OrderBook> = candles
.iter()
.map(|candle| {
let mid = candle.close;
let tick = (mid * 0.0001).max(0.01);
let bids = (0..5u32)
.map(|i| Level::new_unchecked(mid - tick * f64::from(i + 1), 1.0 + f64::from(i)))
.collect();
let asks = (0..5u32)
.map(|i| Level::new_unchecked(mid + tick * f64::from(i + 1), 1.0 + f64::from(i)))
.collect();
OrderBook::new_unchecked(bids, asks)
})
.collect();
bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new);
bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new);
bench_orderbook_input(c, "microprice", &books, Microprice::new);
bench_orderbook_input(c, "depth_slope", &books, DepthSlope::new);
// Synthesise a trade tape from candles: one trade per bar, sided by the
// candle's direction. SignedVolume is the cheapest; TradeImbalance carries
// a rolling window and is the most expensive.
let trades: Vec<Trade> = candles
.iter()
.map(|candle| {
let side = if candle.close >= candle.open {
Side::Buy
} else {
Side::Sell
};
Trade::new_unchecked(candle.close, candle.volume, side, candle.timestamp)
})
.collect();
bench_trade_input(c, "signed_volume", &trades, SignedVolume::new);
bench_trade_input(c, "trade_imbalance", &trades, || {
TradeImbalance::new(50).unwrap()
});
// Pair each synthetic trade with the candle close as the prevailing mid to
// exercise the price-impact family. EffectiveSpread is the stateless
// representative.
let quotes: Vec<TradeQuote> = trades
.iter()
.map(|trade| TradeQuote::new_unchecked(*trade, trade.price))
.collect();
bench_tradequote_input(c, "effective_spread", &quotes, EffectiveSpread::new);
bench_tradequote_input(c, "kyles_lambda", &quotes, || KylesLambda::new(50).unwrap());
// === Family — Derivatives ===
// No derivatives feed ships with the repo, so synthesise a tick per candle:
// the close drives the mark price, funding tracks the candle's body, and
// open interest follows volume. FundingRate is the cheapest (passthrough);
// FundingRateZScore carries a rolling window and is the most expensive.
let ticks: Vec<DerivativesTick> = candles
.iter()
.map(|candle| {
let funding = (candle.close - candle.open) / candle.open * 0.01;
DerivativesTick::new_unchecked(
funding,
candle.close,
candle.close * 0.999,
candle.close * 1.001,
candle.volume * 100.0,
candle.volume * 0.6,
candle.volume * 0.4,
candle.volume * 0.5,
candle.volume * 0.5,
0.0,
0.0,
candle.timestamp,
)
})
.collect();
bench_derivatives_input(c, "funding_rate", &ticks, FundingRate::new);
bench_derivatives_input(c, "funding_rate_zscore", &ticks, || {
FundingRateZScore::new(50).unwrap()
});
}
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
criterion_main!(wickra_benches);
+21
View File
@@ -0,0 +1,21 @@
//! Wickra: streaming-first technical analysis.
//!
//! This crate is a thin re-export of [`wickra_core`] so downstream users can depend on
//! a single `wickra` package without thinking about the internal split. Every public
//! item lives in `wickra_core`; only the names re-exported here are part of the stable
//! public API.
//!
//! # Example
//!
//! ```
//! use wickra::{Indicator, Sma};
//!
//! let mut sma = Sma::new(3).unwrap();
//! let prices = [1.0, 2.0, 3.0, 4.0, 5.0];
//! let out: Vec<Option<f64>> = prices.iter().map(|p| sma.update(*p)).collect();
//! assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]);
//! ```
#![cfg_attr(docsrs, feature(doc_cfg))]
pub use wickra_core::*;
+68
View File
@@ -0,0 +1,68 @@
//! Integration test: the checked-in BTCUSDT example datasets parse cleanly,
//! hold enough rows, and carry strictly increasing — and, for the fixed
//! timeframes, evenly spaced — timestamps.
//!
//! The datasets live in the workspace `examples/data/` directory and are
//! produced by the `fetch_btcusdt` example. Regenerate them with:
//!
//! ```text
//! cargo run -p wickra-examples --bin fetch_btcusdt
//! ```
use wickra_data::csv::CandleReader;
/// `(file name, minimum row count, expected step in ms)`. The step is `None`
/// for the monthly file, whose buckets are 2831 days and thus uneven.
const DATASETS: &[(&str, usize, Option<i64>)] = &[
("btcusdt-1m.csv", 50_000, Some(60_000)),
("btcusdt-5m.csv", 10_000, Some(300_000)),
("btcusdt-15m.csv", 10_000, Some(900_000)),
("btcusdt-1h.csv", 10_000, Some(3_600_000)),
("btcusdt-12h.csv", 5_000, Some(43_200_000)),
// 1d and 1month collect all the history Binance offers, which grows over
// time — assert a lower bound rather than an exact count.
("btcusdt-1d.csv", 3_000, Some(86_400_000)),
("btcusdt-1month.csv", 100, None),
];
fn dataset_path(file: &str) -> String {
format!("{}/../../examples/data/{file}", env!("CARGO_MANIFEST_DIR"))
}
#[test]
fn every_dataset_parses_and_is_well_formed() {
for &(file, min_rows, step) in DATASETS {
let path = dataset_path(file);
let mut reader =
CandleReader::open(&path).unwrap_or_else(|e| panic!("{file}: cannot open {path}: {e}"));
// `read_all` validates every row through `Candle::new`, so a successful
// read already proves each OHLC tuple is finite and internally
// consistent (high >= low, etc.).
let candles = reader
.read_all()
.unwrap_or_else(|e| panic!("{file}: invalid OHLCV row: {e}"));
assert!(
candles.len() >= min_rows,
"{file}: expected at least {min_rows} rows, got {}",
candles.len()
);
for pair in candles.windows(2) {
let (prev, next) = (pair[0], pair[1]);
assert!(
next.timestamp > prev.timestamp,
"{file}: timestamps must strictly increase, saw {} then {}",
prev.timestamp,
next.timestamp
);
if let Some(step) = step {
assert_eq!(
next.timestamp - prev.timestamp,
step,
"{file}: a fixed timeframe must be evenly spaced by {step} ms"
);
}
}
}
}