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