diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2adbbd5c..1b28c7d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d14602c..cec6b9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index e40b8fd2..f6a49afe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ exclude = ["fuzz"] version = "0.2.0" authors = ["kingchenc "] 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" diff --git a/bindings/node/Cargo.toml b/bindings/node/Cargo.toml index 7a2c0050..85db5c29 100644 --- a/bindings/node/Cargo.toml +++ b/bindings/node/Cargo.toml @@ -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 diff --git a/crates/wickra-core/src/indicators/accelerator_oscillator.rs b/crates/wickra-core/src/indicators/accelerator_oscillator.rs index 16d98447..5e985a54 100644 --- a/crates/wickra-core/src/indicators/accelerator_oscillator.rs +++ b/crates/wickra-core/src/indicators/accelerator_oscillator.rs @@ -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] diff --git a/crates/wickra-core/src/indicators/atr_trailing_stop.rs b/crates/wickra-core/src/indicators/atr_trailing_stop.rs index a5bcdfda..b1e7a143 100644 --- a/crates/wickra-core/src/indicators/atr_trailing_stop.rs +++ b/crates/wickra-core/src/indicators/atr_trailing_stop.rs @@ -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] diff --git a/crates/wickra-core/src/indicators/bollinger.rs b/crates/wickra-core/src/indicators/bollinger.rs index d212756b..cb5c5a53 100644 --- a/crates/wickra-core/src/indicators/bollinger.rs +++ b/crates/wickra-core/src/indicators/bollinger.rs @@ -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(); diff --git a/crates/wickra-core/src/indicators/chaikin_volatility.rs b/crates/wickra-core/src/indicators/chaikin_volatility.rs index 419852cc..0a0cc1b8 100644 --- a/crates/wickra-core/src/indicators/chaikin_volatility.rs +++ b/crates/wickra-core/src/indicators/chaikin_volatility.rs @@ -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] diff --git a/crates/wickra-core/src/indicators/chande_kroll_stop.rs b/crates/wickra-core/src/indicators/chande_kroll_stop.rs index 443978ed..e18739f9 100644 --- a/crates/wickra-core/src/indicators/chande_kroll_stop.rs +++ b/crates/wickra-core/src/indicators/chande_kroll_stop.rs @@ -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] diff --git a/crates/wickra-core/src/indicators/chandelier_exit.rs b/crates/wickra-core/src/indicators/chandelier_exit.rs index 321301ec..a734ef5c 100644 --- a/crates/wickra-core/src/indicators/chandelier_exit.rs +++ b/crates/wickra-core/src/indicators/chandelier_exit.rs @@ -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] diff --git a/crates/wickra-core/src/indicators/choppiness_index.rs b/crates/wickra-core/src/indicators/choppiness_index.rs index 04b1b304..1b3aa91a 100644 --- a/crates/wickra-core/src/indicators/choppiness_index.rs +++ b/crates/wickra-core/src/indicators/choppiness_index.rs @@ -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] diff --git a/crates/wickra-core/src/indicators/donchian.rs b/crates/wickra-core/src/indicators/donchian.rs index 5e99252e..546fb3db 100644 --- a/crates/wickra-core/src/indicators/donchian.rs +++ b/crates/wickra-core/src/indicators/donchian.rs @@ -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, }) } diff --git a/crates/wickra-core/src/indicators/ease_of_movement.rs b/crates/wickra-core/src/indicators/ease_of_movement.rs index 5f90aba3..0898eb66 100644 --- a/crates/wickra-core/src/indicators/ease_of_movement.rs +++ b/crates/wickra-core/src/indicators/ease_of_movement.rs @@ -95,7 +95,7 @@ impl Indicator for EaseOfMovement { type Output = f64; fn update(&mut self, candle: Candle) -> Option { - 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); diff --git a/crates/wickra-core/src/indicators/historical_volatility.rs b/crates/wickra-core/src/indicators/historical_volatility.rs index a67b2650..7b97b3c8 100644 --- a/crates/wickra-core/src/indicators/historical_volatility.rs +++ b/crates/wickra-core/src/indicators/historical_volatility.rs @@ -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 = (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); } } diff --git a/crates/wickra-core/src/indicators/sma.rs b/crates/wickra-core/src/indicators/sma.rs index 3f1d035a..8e0ec828 100644 --- a/crates/wickra-core/src/indicators/sma.rs +++ b/crates/wickra-core/src/indicators/sma.rs @@ -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(); diff --git a/crates/wickra-core/src/indicators/super_trend.rs b/crates/wickra-core/src/indicators/super_trend.rs index 52f906e9..e9a2a7de 100644 --- a/crates/wickra-core/src/indicators/super_trend.rs +++ b/crates/wickra-core/src/indicators/super_trend.rs @@ -107,7 +107,7 @@ impl Indicator for SuperTrend { fn update(&mut self, candle: Candle) -> Option { 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] diff --git a/crates/wickra-core/src/indicators/true_range.rs b/crates/wickra-core/src/indicators/true_range.rs index 632c4f5a..357bf490 100644 --- a/crates/wickra-core/src/indicators/true_range.rs +++ b/crates/wickra-core/src/indicators/true_range.rs @@ -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] diff --git a/crates/wickra-core/src/ohlcv.rs b/crates/wickra-core/src/ohlcv.rs index 3d264398..d60b5479 100644 --- a/crates/wickra-core/src/ohlcv.rs +++ b/crates/wickra-core/src/ohlcv.rs @@ -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`. diff --git a/crates/wickra-data/src/aggregator.rs b/crates/wickra-data/src/aggregator.rs index 7f37a2a4..a5913abb 100644 --- a/crates/wickra-data/src/aggregator.rs +++ b/crates/wickra-data/src/aggregator.rs @@ -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` 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) -> 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);