Merge pull request #15 from kingchenc/fix/ci-and-deps

fix(ci): green up the matrix — HV epsilon, examples step, fuzz install, MSRV bump
This commit is contained in:
kingchenc
2026-05-23 20:47:41 +02:00
committed by GitHub
19 changed files with 149 additions and 40 deletions
+33 -16
View File
@@ -48,14 +48,22 @@ jobs:
run: cargo build -p wickra --benches --verbose
- name: Compile examples
run: |
cargo build -p wickra --example backtest
cargo build -p wickra-data --example live_binance --features live-binance
# All runnable examples now live in the dedicated wickra-examples crate
# (examples/rust/src/bin/*.rs) which enables the live-binance feature
# on its wickra-data dep, so a single --bins build covers backtest,
# live_binance, fetch_btcusdt, multi_timeframe, parallel_assets and
# streaming.
run: cargo build -p wickra-examples --bins
# Verify the crates still build and test on their declared minimum supported
# Rust version. The workspace pins rust-version = "1.75"; bindings/node needs
# 1.77 because napi-build emits `cargo::` directives. Without this job an
# accidental use of a newer API would only surface for downstream users.
# Rust version. The workspace pins rust-version = "1.85" — that floor is
# set by clap_lex 1.1.0 (transitively pulled in by the criterion dev-dep)
# which needs the stabilized edition2024 feature (stable since Rust 1.85);
# rayon-core 1.13.0 only needed 1.80 and is subsumed by the 1.85 floor.
# bindings/node pins rust-version = "1.88" because napi-build 2.3.2
# requires it (and that subsumes the older 1.77 floor needed for
# `cargo::` directives). Without this job an accidental use of a newer
# API would only surface for downstream users.
msrv:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
@@ -63,11 +71,11 @@ jobs:
fail-fast: false
matrix:
include:
- name: MSRV workspace (Rust 1.75)
toolchain: "1.75"
- name: MSRV workspace (Rust 1.85)
toolchain: "1.85"
packages: "-p wickra-core -p wickra -p wickra-data"
- name: MSRV node binding (Rust 1.77)
toolchain: "1.77"
- name: MSRV node binding (Rust 1.88)
toolchain: "1.88"
packages: "-p wickra-node"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -156,26 +164,35 @@ jobs:
workspaces: fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
# Use the prebuilt binary from taiki-e/install-action instead of
# `cargo install cargo-fuzz --locked`. The latter resolves the
# version graph from cargo-fuzz's own Cargo.lock, which pins to
# rustix 0.36.5 — a version that still uses internal `rustc_*`
# attributes the modern nightly compiler rejects, so the install
# never gets off the ground. The prebuilt binary avoids the entire
# transitive-dep compile.
uses: taiki-e/install-action@e0eafa9a0d485c37f97c0f7beb930a58a2facbac # v2.79.4
with:
tool: cargo-fuzz
- name: Fuzz csv_reader (30 s)
run: cargo +nightly fuzz run csv_reader -- -max_total_time=30
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu csv_reader -- -max_total_time=30
working-directory: fuzz
- name: Fuzz binance_envelope (30 s)
run: cargo +nightly fuzz run binance_envelope -- -max_total_time=30
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu binance_envelope -- -max_total_time=30
working-directory: fuzz
- name: Fuzz indicator_update (30 s)
run: cargo +nightly fuzz run indicator_update -- -max_total_time=30
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update -- -max_total_time=30
working-directory: fuzz
- name: Fuzz indicator_update_candle (30 s)
run: cargo +nightly fuzz run indicator_update_candle -- -max_total_time=30
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update_candle -- -max_total_time=30
working-directory: fuzz
- name: Fuzz tick_aggregator (30 s)
run: cargo +nightly fuzz run tick_aggregator -- -max_total_time=30
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu tick_aggregator -- -max_total_time=30
working-directory: fuzz
python:
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **MSRV bumped.** Workspace minimum supported Rust version is now **1.85**
(was 1.75) and the Node binding (`wickra-node`) is now **1.88** (was 1.77).
The bumps are driven by transitive-dependency floors that were lifted in
recent updates: `clap_lex >= 1.1.0` (pulled in via the criterion dev-dep)
requires the stabilized `edition2024` feature (stable since Rust 1.85),
and `napi-build >= 2.3.2` requires Rust 1.88. Pinning the deps to the
older versions would have frozen us out of future security fixes from
those upstreams, so lifting the MSRV is the cleaner path for a young 0.x
library. Downstream consumers on older Rust toolchains can stay on
Wickra 0.2.0.
## [0.2.0] - 2026-05-23
### Fixed
+1 -1
View File
@@ -15,7 +15,7 @@ exclude = ["fuzz"]
version = "0.2.0"
authors = ["kingchenc <kingchencp@gmail.com>"]
edition = "2021"
rust-version = "1.75"
rust-version = "1.85"
license = "PolyForm-Noncommercial-1.0.0"
repository = "https://github.com/kingchenc/wickra"
homepage = "https://github.com/kingchenc/wickra"
+5 -3
View File
@@ -4,9 +4,11 @@ description = "Node.js bindings for the Wickra streaming-first technical indicat
version.workspace = true
authors.workspace = true
edition.workspace = true
# napi-build emits `cargo::` directives that require Rust >= 1.77; the rest of
# the workspace stays at 1.75 because the core crate has no such dependency.
rust-version = "1.77"
# napi-build 2.3.2 requires Rust >= 1.88 (newer than the workspace 1.80
# minimum, which itself was lifted to satisfy rayon-core 1.13.0). napi-build
# also emits `cargo::` directives that require >= 1.77 — that older floor is
# subsumed by the 1.88 requirement now.
rust-version = "1.88"
license.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -108,7 +108,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -139,7 +139,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -294,7 +294,7 @@ mod tests {
let n_updates = 16 * period * 5;
let mut last = None;
for i in 0..n_updates {
let v = if i.is_multiple_of(2) { 1e6 } else { 1.0 };
let v = if i % 2 == 0 { 1e6 } else { 1.0 };
last = bb.update(v);
if window.len() == period {
window.pop_front();
@@ -108,7 +108,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -173,7 +173,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -137,7 +137,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -134,7 +134,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -83,7 +83,7 @@ impl Indicator for Donchian {
.fold(f64::INFINITY, f64::min);
Some(DonchianOutput {
upper,
middle: (upper + lower) / 2.0,
middle: f64::midpoint(upper, lower),
lower,
})
}
@@ -95,7 +95,7 @@ impl Indicator for EaseOfMovement {
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let mid = (candle.high + candle.low) / 2.0;
let mid = f64::midpoint(candle.high, candle.low);
let Some(prev_mid) = self.prev_mid else {
// The first candle only establishes the previous midpoint.
self.prev_mid = Some(mid);
@@ -205,11 +205,17 @@ mod tests {
#[test]
fn geometric_series_yields_zero() {
// A constant growth factor gives a constant log return -> zero stddev.
// The mathematical result is exactly zero, but `1.01_f64.powi(i)` and
// the subsequent log / std-dev cascade accumulate platform-sensitive
// floating-point drift on the order of 1e-7 (observed on x86_64 Linux
// and macOS; Windows happens to round closer to zero). The 1e-6
// tolerance stays four decimal places below any realistic volatility
// value while absorbing this drift across every supported platform.
let mut hv = HistoricalVolatility::new(10, 252).unwrap();
let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
let out = hv.batch(&prices);
for v in out.iter().skip(10).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
assert_relative_eq!(*v, 0.0, epsilon = 1e-6);
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ mod tests {
// `RECOMPUTE_EVERY * period * 5` updates → recompute fires 5+ times.
let n_updates = 16 * period * 5;
for i in 0..n_updates {
let v = if i.is_multiple_of(2) { 1e9 } else { 1.0 };
let v = if i % 2 == 0 { 1e9 } else { 1.0 };
sma.update(v);
if window.len() == period {
window.pop_front();
@@ -107,7 +107,7 @@ impl Indicator for SuperTrend {
fn update(&mut self, candle: Candle) -> Option<SuperTrendOutput> {
let atr = self.atr.update(candle)?;
let hl2 = (candle.high + candle.low) / 2.0;
let hl2 = f64::midpoint(candle.high, candle.low);
let basic_upper = hl2 + self.multiplier * atr;
let basic_lower = hl2 - self.multiplier * atr;
@@ -184,7 +184,7 @@ mod tests {
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
@@ -82,7 +82,7 @@ mod tests {
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
+1 -1
View File
@@ -110,7 +110,7 @@ impl Candle {
/// The mid price `(high + low) / 2`.
#[inline]
pub fn median_price(&self) -> f64 {
(self.high + self.low) / 2.0
f64::midpoint(self.high, self.low)
}
/// The weighted close `(high + low + 2*close) / 4`.
+77 -5
View File
@@ -3,6 +3,15 @@
use crate::error::{Error, Result};
use wickra_core::{Candle, Tick};
/// Hard cap on the number of placeholder candles a single
/// [`TickAggregator::push`] call may emit when gap-fill is enabled. One
/// million minute-candles is roughly 1.9 years of contiguous one-minute bars
/// — orders of magnitude beyond any realistic missing-data window in
/// production while still keeping the resulting `Vec<Candle>` to well under
/// 50 MB. Any larger gap is treated as malformed input rather than allowed
/// to OOM the process.
pub const MAX_GAP_FILL_CANDLES: i64 = 1_000_000;
/// A candle bucket size measured in the same unit as the tick timestamps.
///
/// Wickra is unit-agnostic about timestamps: choose whichever makes sense for
@@ -133,7 +142,11 @@ impl Timeframe {
/// the next non-empty bar — the skipped buckets produce no candle, so the
/// output series can have time holes. Enable [`TickAggregator::with_gap_fill`]
/// to instead emit a flat placeholder candle for every skipped bucket, giving
/// downstream indicators an unbroken, evenly spaced series.
/// downstream indicators an unbroken, evenly spaced series. To bound memory
/// against an adversarial timestamp jump, gap-filling refuses to emit more
/// than [`MAX_GAP_FILL_CANDLES`] placeholders in a single step; a larger gap
/// surfaces as an `Error::Malformed` so the caller can decide how to handle
/// the discontinuity.
#[derive(Debug, Clone)]
pub struct TickAggregator {
timeframe: Timeframe,
@@ -279,19 +292,47 @@ impl TickAggregator {
/// Append a flat placeholder candle for every empty bucket strictly between
/// the just-closed bar and the next bucket that received a tick.
///
/// Returns `Error::Malformed` when the gap would exceed
/// [`MAX_GAP_FILL_CANDLES`] — an adversarial timestamp jump (a clock-glitch
/// tick years in the future) must surface as a defined error, not as an
/// out-of-memory panic from allocating millions of placeholder candles.
fn fill_between(&self, prev: Candle, next_bucket: i64, out: &mut Vec<Candle>) -> Result<()> {
let step = self.timeframe.bucket();
let mut start = prev
let start = prev
.timestamp
.checked_add(step)
.ok_or_else(|| Error::Malformed("timestamp overflow while gap-filling".to_string()))?;
while start < next_bucket {
if start >= next_bucket {
return Ok(());
}
// Compute the gap size up-front so an adversarial timestamp delta
// is refused before we allocate. `step > 0` by `Timeframe::new`'s
// invariant, so the divisor is safe. Saturating the subtraction
// makes the arithmetic infallible; an overflowed-saturated span is
// still far above the cap so the limit check below catches it.
let span = next_bucket.saturating_sub(start);
let gap_count = span / step + i64::from(span % step != 0);
if gap_count > MAX_GAP_FILL_CANDLES {
return Err(Error::Malformed(format!(
"gap-fill between bucket {} and {next_bucket} would emit {gap_count} \
flat candles at step {step}, exceeding the {MAX_GAP_FILL_CANDLES} \
cap; reject the discontinuity instead of allocating",
prev.timestamp
)));
}
out.reserve(gap_count as usize);
let mut t = start;
while t < next_bucket {
// `prev.close` is finite (it came from a validated bar), so this
// flat candle always passes `Candle::new`'s checks.
out.push(Candle::new(
prev.close, prev.close, prev.close, prev.close, 0.0, start,
prev.close, prev.close, prev.close, prev.close, 0.0, t,
)?);
start = start.checked_add(step).ok_or_else(|| {
t = t.checked_add(step).ok_or_else(|| {
Error::Malformed("timestamp overflow while gap-filling".to_string())
})?;
}
@@ -477,6 +518,37 @@ mod tests {
assert_eq!(closed[0].timestamp, 0);
}
#[test]
fn gap_fill_rejects_runaway_timestamp_jump() {
// An adversarial clock-glitch tick years in the future must surface
// as an Error::Malformed rather than allocating millions of flat
// candles and OOMing. Found by the `tick_aggregator` fuzz target.
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true);
agg.push(t(10.0, 0)).unwrap();
// Two-billion-second jump = ~63 years of minute bars = ~33 million
// candles, well above the 1_000_000 cap.
let err = agg.push(t(20.0, 2_000_000_000)).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("gap-fill") && msg.contains("cap"),
"expected a malformed-gap error, got: {msg}"
);
}
#[test]
fn gap_fill_at_the_cap_succeeds() {
// Exactly one million minute-buckets between the two ticks (one real
// bar + one million flat fillers + the third tick's open bar) — the
// limit is inclusive, so this must succeed.
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true);
agg.push(t(10.0, 0)).unwrap();
// bucket 0 closes; jump straight to bucket 60_000_060 (1_000_001 buckets
// away). fill_between emits 1_000_000 flat candles between them, then
// the new tick opens its own bucket. Output: 1 real bar + 1_000_000 fillers.
let out = agg.push(t(20.0, 60_000_060)).unwrap();
assert_eq!(out.len(), 1 + MAX_GAP_FILL_CANDLES as usize);
}
#[test]
fn gap_fill_emits_flat_candles_for_skipped_buckets() {
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true);