From ae8fcd90515a4d7f3f2b1498f2bd1101850c64e8 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:18:24 +0200 Subject: [PATCH 1/7] test(hv): widen geometric_series_yields_zero tolerance to 1e-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mathematical result of HistoricalVolatility on a perfectly geometric price series is exactly zero — but the underlying 1.01_f64.powi(i) + log-return + std-dev cascade accumulates platform-sensitive FP drift on the order of 1e-7 on x86_64 Linux and macOS (the Windows result happened to round closer to zero, which is why the test passed locally and on the Windows CI runner but failed on Linux and macOS). Bump the tolerance from 1e-9 to 1e-6. That stays four decimal places below any realistic annualised volatility value while comfortably absorbing the observed cross-platform drift. Also extend the comment to document the rationale so the next person who reads the test does not tighten it back down. --- .../wickra-core/src/indicators/historical_volatility.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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); } } From 73f8da49bd1897a57440fd9e95729ea90695d37a Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:19:14 +0200 Subject: [PATCH 2/7] ci: build wickra-examples bins instead of removed cargo examples The Z5 reorganisation moved every runnable example out of the per-crate examples/ folders and into a dedicated wickra-examples crate at examples/rust/, with the binaries living under src/bin/.rs. The old ci.yml Compile-examples step still pointed at the now-deleted cargo example targets backtest (wickra) and live_binance (wickra-data), which is why Rust windows-latest failed with 'no example target named backtest in wickra package' and 'no example target named live_binance in wickra-data package'. Replace both calls with a single cargo build -p wickra-examples --bins. That covers backtest, live_binance, fetch_btcusdt, multi_timeframe, parallel_assets and streaming in one shot, and the wickra-examples crate already enables the live-binance feature on its wickra-data dep so no extra --features flag is needed. --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2adbbd5c..8720fa60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,9 +48,12 @@ 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 From 90d8a299c3017e71447b0f6cff23e61130733734 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:21:03 +0200 Subject: [PATCH 3/7] ci(fuzz): install cargo-fuzz from a prebuilt binary The previous `cargo install cargo-fuzz --locked` resolved against cargo-fuzz's own Cargo.lock which pins to rustix 0.36.5. That rustix version still annotates its source with internal #[rustc_*] attributes, and the current nightly compiler rejects those attributes from out-of-tree crates, so cargo-fuzz never finished compiling on CI: error: attributes starting with `rustc` are reserved for use by the `rustc` compiler --> rustix-0.36.5/src/backend/linux_raw/io/errno.rs:28 error: could not compile `rustix` (lib) due to 4 previous errors error: failed to compile `cargo-fuzz v0.13.1` Switch the install to taiki-e/install-action, the same prebuilt-binary provider we already use for cargo-llvm-cov. That skips the full transitive-dependency compile and lets the fuzz-smoke job actually run. --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8720fa60..ab871f7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,7 +159,16 @@ 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 From 30444ce296ddd65daa95f1e3e9a00e50ae09bc8e Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:22:35 +0200 Subject: [PATCH 4/7] build: lift MSRV to 1.80 workspace / 1.88 node binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous MSRV pins were below what current transitive dependencies need: - rayon-core 1.13.0 (pulled in by the optional `parallel` feature on wickra-core via rayon) requires rustc >= 1.80; under the old 1.75 workspace MSRV the CI MSRV job broke with "package `rayon-core v1.13.0` cannot be built because it requires rustc 1.80 or newer". - napi-build 2.3.2 (the build-script crate that napi-derive 2.x calls into) requires rustc >= 1.88; under the old 1.77 node-binding MSRV the CI MSRV-node job broke with "package `napi-build v2.3.2` cannot be built because it requires rustc 1.88 or newer". Pinning the deps backwards would have frozen us out of upstream security/fix releases for both crates. Lifting the MSRV is the cleaner path for a young 0.x library — downstream consumers on older toolchains can stay on the already-published 0.2.0. Updated: - Cargo.toml workspace rust-version 1.75 -> 1.80 - bindings/node/Cargo.toml rust-version 1.77 -> 1.88 - .github/workflows/ci.yml MSRV matrix names + toolchain values + comment - CHANGELOG.md [Unreleased] documents the bump --- .github/workflows/ci.yml | 17 ++++++++++------- CHANGELOG.md | 11 +++++++++++ Cargo.toml | 2 +- bindings/node/Cargo.toml | 8 +++++--- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab871f7d..a2446471 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,12 @@ jobs: 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.80" (raised from 1.75 + # so rayon-core >= 1.13.0 keeps compiling); 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 @@ -66,11 +69,11 @@ jobs: fail-fast: false matrix: include: - - name: MSRV workspace (Rust 1.75) - toolchain: "1.75" + - name: MSRV workspace (Rust 1.80) + toolchain: "1.80" 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d14602c..e3ed7cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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.80** + (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: `rayon-core >= 1.13.0` requires Rust 1.80, 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..68c0e545 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.80" 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 From 3f9429dd2ca0ecbd9e3d3b89d99a6fb53df5962e Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:30:46 +0200 Subject: [PATCH 5/7] build: lift workspace MSRV further to 1.85 for criterion's clap_lex The first MSRV bump to 1.80 fixed the rayon-core floor but ran into a second transitive-dep floor on the same CI run: error: failed to parse manifest at clap_lex-1.1.0/Cargo.toml Caused by: feature `edition2024` is required The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo (1.80.1). clap_lex 1.1.0 is pulled in by criterion (dev-dep on the wickra crate) via clap 4.6 -> clap_builder 4.6 -> clap_lex 1.1. edition2024 was stabilized in Rust 1.85, so lift the workspace MSRV one more step. The 1.85 floor subsumes the rayon-core 1.80 requirement; bindings/node stays at 1.88 (napi-build) which already covers everything below it. Also fix the fuzz job: cargo-fuzz defaulted to building for x86_64-unknown-linux-musl, which is not installed on the GitHub-hosted ubuntu runner. Pass --target x86_64-unknown-linux-gnu explicitly on every cargo fuzz run invocation so it builds for the actual host target. --- .github/workflows/ci.yml | 28 +++++++++++++++------------- CHANGELOG.md | 11 ++++++----- Cargo.toml | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2446471..1b28c7d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,14 @@ jobs: 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.80" (raised from 1.75 - # so rayon-core >= 1.13.0 keeps compiling); 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. + # 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 @@ -69,8 +71,8 @@ jobs: fail-fast: false matrix: include: - - name: MSRV workspace (Rust 1.80) - toolchain: "1.80" + - name: MSRV workspace (Rust 1.85) + toolchain: "1.85" packages: "-p wickra-core -p wickra -p wickra-data" - name: MSRV node binding (Rust 1.88) toolchain: "1.88" @@ -174,23 +176,23 @@ jobs: 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 e3ed7cc3..cec6b9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,14 @@ 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.80** +- **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: `rayon-core >= 1.13.0` requires Rust 1.80, 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 + 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. diff --git a/Cargo.toml b/Cargo.toml index 68c0e545..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.80" +rust-version = "1.85" license = "PolyForm-Noncommercial-1.0.0" repository = "https://github.com/kingchenc/wickra" homepage = "https://github.com/kingchenc/wickra" From d52ddeaccbeb10e457280067df4e26f96a7de282 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:37:35 +0200 Subject: [PATCH 6/7] fix(core): use f64::midpoint and stable %2 to satisfy newer toolchains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated newer-toolchain breakages bundled because they hit on the same CI run and have the same shape (newer Rust got stricter about patterns we used): 1. clippy 1.95 added the manual_midpoint lint which fires on every instance of (a + b) / 2.0 with a help suggesting f64::midpoint. CI runs with -D warnings so it became a hard error. Twelve sites were affected — three real call sites in src/ohlcv.rs (median_price), src/indicators/donchian.rs (DonchianOutput.middle), src/indicators/ease_of_movement.rs (mid), and src/indicators/super_trend.rs (hl2); plus eight test-helper Candle::new constructions across accelerator_oscillator, atr_trailing_stop, chaikin_volatility, chandelier_exit, chande_kroll_stop, choppiness_index, super_trend, true_range. All twelve switched to f64::midpoint (stable since Rust 1.85, our workspace MSRV). 2. usize::is_multiple_of is still unstable (rust-lang/rust#128101) and only stabilizes in Rust 1.87, but the MSRV CI job uses 1.85. The two call sites in bollinger.rs and sma.rs (added with the R7 periodic-reseed tests) switched back to i % 2 == 0. --- crates/wickra-core/src/indicators/accelerator_oscillator.rs | 2 +- crates/wickra-core/src/indicators/atr_trailing_stop.rs | 2 +- crates/wickra-core/src/indicators/bollinger.rs | 2 +- crates/wickra-core/src/indicators/chaikin_volatility.rs | 2 +- crates/wickra-core/src/indicators/chande_kroll_stop.rs | 2 +- crates/wickra-core/src/indicators/chandelier_exit.rs | 2 +- crates/wickra-core/src/indicators/choppiness_index.rs | 2 +- crates/wickra-core/src/indicators/donchian.rs | 2 +- crates/wickra-core/src/indicators/ease_of_movement.rs | 2 +- crates/wickra-core/src/indicators/sma.rs | 2 +- crates/wickra-core/src/indicators/super_trend.rs | 4 ++-- crates/wickra-core/src/indicators/true_range.rs | 2 +- crates/wickra-core/src/ohlcv.rs | 2 +- 13 files changed, 14 insertions(+), 14 deletions(-) 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/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`. From df179fa5799ec323dac2e8596a1e29c01291a308 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 20:41:03 +0200 Subject: [PATCH 7/7] fix(aggregator): cap gap-fill at 1_000_000 candles per push (fuzz finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tick_aggregator fuzz target found that TickAggregator::fill_between allocates one placeholder Candle per skipped bucket without bounding the gap size. An adversarial input (a clock-glitch tick years in the future) produced an OOM on libFuzzer after malloc(~3 GB): SUMMARY: libFuzzer: out-of-memory (malloc(3221225472)) A real-world failure mode too: a single bad timestamp from a flaky feed could OOM the host process even though every individual tick passed Tick::new validation. Fix: - Compute the gap size up-front via saturating arithmetic, before any allocation, and refuse with Error::Malformed when it exceeds the new MAX_GAP_FILL_CANDLES = 1_000_000 cap (≈ 1.9 years of contiguous one-minute bars, well above any realistic missing-data window). - Reserve the right Vec capacity in advance once we know the gap fits, avoiding intermediate reallocations. - Add two regression tests: gap_fill_rejects_runaway_timestamp_jump (the fuzz scenario) and gap_fill_at_the_cap_succeeds (exact-cap input still works). The cap is exposed as pub const so callers can pre-validate their input without relying on the error string. --- crates/wickra-data/src/aggregator.rs | 82 ++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) 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);