wickra-data: add minutes/hours/days Timeframe constructors

Timeframe gained new/millis/seconds/one_minute_ms; add minutes, hours and
days alongside them. Each builds on seconds (minutes(5) -> a 300-second
bucket), consistent with Timeframe::seconds, and guards the multiplication
with checked_mul so an oversized n yields Error::InvalidTimeframe instead
of an overflow panic. A non-positive n is rejected by Timeframe::new.

Each method carries a runnable doctest, and unit tests cover the known
bucket sizes, non-positive rejection and overflow rejection.
This commit is contained in:
kingchenc
2026-05-22 21:49:21 +02:00
parent 2b3a1b7384
commit d5ff0a9df6
2 changed files with 101 additions and 0 deletions
+97
View File
@@ -42,6 +42,67 @@ impl Timeframe {
Self::new(60_000).expect("60_000 > 0") Self::new(60_000).expect("60_000 > 0")
} }
/// Convenience: build a timeframe of `n` whole minutes, measured in
/// seconds — consistent with [`Timeframe::seconds`].
///
/// `minutes(5)` yields a bucket of `300`, for use with second-resolution
/// timestamps. For millisecond timestamps (Binance) multiply yourself or
/// use [`Timeframe::millis`].
///
/// # Errors
/// Returns [`Error::InvalidTimeframe`] if `n` is not positive or if
/// `n * 60` overflows `i64`.
///
/// ```
/// use wickra_data::aggregator::Timeframe;
/// assert_eq!(Timeframe::minutes(5)?.bucket(), 300);
/// # Ok::<(), wickra_data::Error>(())
/// ```
pub fn minutes(n: i64) -> Result<Self> {
let bucket = n
.checked_mul(60)
.ok_or_else(|| Error::InvalidTimeframe(format!("{n} minutes overflows i64 seconds")))?;
Self::new(bucket)
}
/// Convenience: build a timeframe of `n` whole hours, measured in seconds
/// (`hours(2)` → a bucket of `7_200`).
///
/// # Errors
/// Returns [`Error::InvalidTimeframe`] if `n` is not positive or if
/// `n * 3_600` overflows `i64`.
///
/// ```
/// use wickra_data::aggregator::Timeframe;
/// assert_eq!(Timeframe::hours(2)?.bucket(), 7_200);
/// # Ok::<(), wickra_data::Error>(())
/// ```
pub fn hours(n: i64) -> Result<Self> {
let bucket = n
.checked_mul(3_600)
.ok_or_else(|| Error::InvalidTimeframe(format!("{n} hours overflows i64 seconds")))?;
Self::new(bucket)
}
/// Convenience: build a timeframe of `n` whole days, measured in seconds
/// (`days(1)` → a bucket of `86_400`).
///
/// # Errors
/// Returns [`Error::InvalidTimeframe`] if `n` is not positive or if
/// `n * 86_400` overflows `i64`.
///
/// ```
/// use wickra_data::aggregator::Timeframe;
/// assert_eq!(Timeframe::days(1)?.bucket(), 86_400);
/// # Ok::<(), wickra_data::Error>(())
/// ```
pub fn days(n: i64) -> Result<Self> {
let bucket = n
.checked_mul(86_400)
.ok_or_else(|| Error::InvalidTimeframe(format!("{n} days overflows i64 seconds")))?;
Self::new(bucket)
}
/// Bucket size. /// Bucket size.
pub const fn bucket(self) -> i64 { pub const fn bucket(self) -> i64 {
self.bucket self.bucket
@@ -267,6 +328,42 @@ mod tests {
assert!(Timeframe::new(-1).is_err()); assert!(Timeframe::new(-1).is_err());
} }
#[test]
fn minute_hour_day_constructors_compute_seconds() {
assert_eq!(Timeframe::minutes(1).unwrap().bucket(), 60);
assert_eq!(Timeframe::minutes(5).unwrap().bucket(), 300);
assert_eq!(Timeframe::hours(1).unwrap().bucket(), 3_600);
assert_eq!(Timeframe::hours(4).unwrap().bucket(), 14_400);
assert_eq!(Timeframe::days(1).unwrap().bucket(), 86_400);
assert_eq!(Timeframe::days(7).unwrap().bucket(), 604_800);
}
#[test]
fn minute_hour_day_constructors_reject_non_positive() {
for n in [0, -1, -60] {
assert!(Timeframe::minutes(n).is_err());
assert!(Timeframe::hours(n).is_err());
assert!(Timeframe::days(n).is_err());
}
}
#[test]
fn minute_hour_day_constructors_reject_overflow() {
// `n * unit` overflows i64 long before `new`'s sign check runs.
assert!(matches!(
Timeframe::minutes(i64::MAX),
Err(Error::InvalidTimeframe(_))
));
assert!(matches!(
Timeframe::hours(i64::MAX),
Err(Error::InvalidTimeframe(_))
));
assert!(matches!(
Timeframe::days(i64::MAX),
Err(Error::InvalidTimeframe(_))
));
}
#[test] #[test]
fn floors_to_bucket_boundary() { fn floors_to_bucket_boundary() {
let tf = Timeframe::new(100).unwrap(); let tf = Timeframe::new(100).unwrap();
+4
View File
@@ -51,6 +51,10 @@ The reader is defensive about real-world files:
`TickAggregator` rolls a stream of trade `Tick`s up into `Candle`s of an `TickAggregator` rolls a stream of trade `Tick`s up into `Candle`s of an
arbitrary timeframe. The timeframe's bucket size is in the same unit as the arbitrary timeframe. The timeframe's bucket size is in the same unit as the
tick timestamps (milliseconds for Binance, seconds for daily bars, …). tick timestamps (milliseconds for Binance, seconds for daily bars, …).
Build a `Timeframe` with `Timeframe::new` (a raw bucket size), the
`millis` / `seconds` / `one_minute_ms` shortcuts, or the `minutes` / `hours` /
`days` constructors — each of the last three builds on **seconds**, so
`Timeframe::minutes(5)` is a 300-second bucket.
```rust ```rust
use wickra_data::aggregator::{TickAggregator, Timeframe}; use wickra_data::aggregator::{TickAggregator, Timeframe};