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:
+33
-16
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user