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 run: cargo build -p wickra --benches --verbose
- name: Compile examples - name: Compile examples
run: | # All runnable examples now live in the dedicated wickra-examples crate
cargo build -p wickra --example backtest # (examples/rust/src/bin/*.rs) which enables the live-binance feature
cargo build -p wickra-data --example live_binance --features live-binance # 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 # Verify the crates still build and test on their declared minimum supported
# Rust version. The workspace pins rust-version = "1.75"; bindings/node needs # Rust version. The workspace pins rust-version = "1.85" — that floor is
# 1.77 because napi-build emits `cargo::` directives. Without this job an # set by clap_lex 1.1.0 (transitively pulled in by the criterion dev-dep)
# accidental use of a newer API would only surface for downstream users. # 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: msrv:
name: ${{ matrix.name }} name: ${{ matrix.name }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -63,11 +71,11 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- name: MSRV workspace (Rust 1.75) - name: MSRV workspace (Rust 1.85)
toolchain: "1.75" toolchain: "1.85"
packages: "-p wickra-core -p wickra -p wickra-data" packages: "-p wickra-core -p wickra -p wickra-data"
- name: MSRV node binding (Rust 1.77) - name: MSRV node binding (Rust 1.88)
toolchain: "1.77" toolchain: "1.88"
packages: "-p wickra-node" packages: "-p wickra-node"
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -156,26 +164,35 @@ jobs:
workspaces: fuzz workspaces: fuzz
- name: Install cargo-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) - 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 working-directory: fuzz
- name: Fuzz binance_envelope (30 s) - 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 working-directory: fuzz
- name: Fuzz indicator_update (30 s) - 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 working-directory: fuzz
- name: Fuzz indicator_update_candle (30 s) - 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 working-directory: fuzz
- name: Fuzz tick_aggregator (30 s) - 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 working-directory: fuzz
python: python:
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.2.0] - 2026-05-23
### Fixed ### Fixed
+1 -1
View File
@@ -15,7 +15,7 @@ exclude = ["fuzz"]
version = "0.2.0" version = "0.2.0"
authors = ["kingchenc <kingchencp@gmail.com>"] authors = ["kingchenc <kingchencp@gmail.com>"]
edition = "2021" edition = "2021"
rust-version = "1.75" rust-version = "1.85"
license = "PolyForm-Noncommercial-1.0.0" license = "PolyForm-Noncommercial-1.0.0"
repository = "https://github.com/kingchenc/wickra" repository = "https://github.com/kingchenc/wickra"
homepage = "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 version.workspace = true
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
# napi-build emits `cargo::` directives that require Rust >= 1.77; the rest of # napi-build 2.3.2 requires Rust >= 1.88 (newer than the workspace 1.80
# the workspace stays at 1.75 because the core crate has no such dependency. # minimum, which itself was lifted to satisfy rayon-core 1.13.0). napi-build
rust-version = "1.77" # 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 license.workspace = true
repository.workspace = true repository.workspace = true
homepage.workspace = true homepage.workspace = true
@@ -108,7 +108,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -139,7 +139,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -294,7 +294,7 @@ mod tests {
let n_updates = 16 * period * 5; let n_updates = 16 * period * 5;
let mut last = None; let mut last = None;
for i in 0..n_updates { 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); last = bb.update(v);
if window.len() == period { if window.len() == period {
window.pop_front(); window.pop_front();
@@ -108,7 +108,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -173,7 +173,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -137,7 +137,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -134,7 +134,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -83,7 +83,7 @@ impl Indicator for Donchian {
.fold(f64::INFINITY, f64::min); .fold(f64::INFINITY, f64::min);
Some(DonchianOutput { Some(DonchianOutput {
upper, upper,
middle: (upper + lower) / 2.0, middle: f64::midpoint(upper, lower),
lower, lower,
}) })
} }
@@ -95,7 +95,7 @@ impl Indicator for EaseOfMovement {
type Output = f64; type Output = f64;
fn update(&mut self, candle: Candle) -> Option<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 { let Some(prev_mid) = self.prev_mid else {
// The first candle only establishes the previous midpoint. // The first candle only establishes the previous midpoint.
self.prev_mid = Some(mid); self.prev_mid = Some(mid);
@@ -205,11 +205,17 @@ mod tests {
#[test] #[test]
fn geometric_series_yields_zero() { fn geometric_series_yields_zero() {
// A constant growth factor gives a constant log return -> zero stddev. // 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 mut hv = HistoricalVolatility::new(10, 252).unwrap();
let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
let out = hv.batch(&prices); let out = hv.batch(&prices);
for v in out.iter().skip(10).flatten() { 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. // `RECOMPUTE_EVERY * period * 5` updates → recompute fires 5+ times.
let n_updates = 16 * period * 5; let n_updates = 16 * period * 5;
for i in 0..n_updates { 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); sma.update(v);
if window.len() == period { if window.len() == period {
window.pop_front(); window.pop_front();
@@ -107,7 +107,7 @@ impl Indicator for SuperTrend {
fn update(&mut self, candle: Candle) -> Option<SuperTrendOutput> { fn update(&mut self, candle: Candle) -> Option<SuperTrendOutput> {
let atr = self.atr.update(candle)?; 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_upper = hl2 + self.multiplier * atr;
let basic_lower = hl2 - self.multiplier * atr; let basic_lower = hl2 - self.multiplier * atr;
@@ -184,7 +184,7 @@ mod tests {
use crate::traits::BatchExt; use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
@@ -82,7 +82,7 @@ mod tests {
use approx::assert_relative_eq; use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { 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] #[test]
+1 -1
View File
@@ -110,7 +110,7 @@ impl Candle {
/// The mid price `(high + low) / 2`. /// The mid price `(high + low) / 2`.
#[inline] #[inline]
pub fn median_price(&self) -> f64 { 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`. /// The weighted close `(high + low + 2*close) / 4`.
+77 -5
View File
@@ -3,6 +3,15 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use wickra_core::{Candle, Tick}; 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. /// A candle bucket size measured in the same unit as the tick timestamps.
/// ///
/// Wickra is unit-agnostic about timestamps: choose whichever makes sense for /// 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 /// the next non-empty bar — the skipped buckets produce no candle, so the
/// output series can have time holes. Enable [`TickAggregator::with_gap_fill`] /// output series can have time holes. Enable [`TickAggregator::with_gap_fill`]
/// to instead emit a flat placeholder candle for every skipped bucket, giving /// 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)] #[derive(Debug, Clone)]
pub struct TickAggregator { pub struct TickAggregator {
timeframe: Timeframe, timeframe: Timeframe,
@@ -279,19 +292,47 @@ impl TickAggregator {
/// Append a flat placeholder candle for every empty bucket strictly between /// Append a flat placeholder candle for every empty bucket strictly between
/// the just-closed bar and the next bucket that received a tick. /// 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<()> { fn fill_between(&self, prev: Candle, next_bucket: i64, out: &mut Vec<Candle>) -> Result<()> {
let step = self.timeframe.bucket(); let step = self.timeframe.bucket();
let mut start = prev let start = prev
.timestamp .timestamp
.checked_add(step) .checked_add(step)
.ok_or_else(|| Error::Malformed("timestamp overflow while gap-filling".to_string()))?; .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 // `prev.close` is finite (it came from a validated bar), so this
// flat candle always passes `Candle::new`'s checks. // flat candle always passes `Candle::new`'s checks.
out.push(Candle::new( 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()) Error::Malformed("timestamp overflow while gap-filling".to_string())
})?; })?;
} }
@@ -477,6 +518,37 @@ mod tests {
assert_eq!(closed[0].timestamp, 0); 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] #[test]
fn gap_fill_emits_flat_candles_for_skipped_buckets() { fn gap_fill_emits_flat_candles_for_skipped_buckets() {
let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true); let mut agg = TickAggregator::new(Timeframe::new(60).unwrap()).with_gap_fill(true);