## Summary Adds the **Seasonality & Session** family — the first family that reads the wall-clock fields of `Candle::timestamp`. A new private `calendar` module decomposes an epoch-millisecond instant (shifted by a per-indicator `utc_offset_minutes`) into civil fields via Howard Hinnant's branch-light `civil_from_days` algorithm. Session / day / month rollovers are detected automatically, so callers never have to invoke `reset()` at a boundary. Indicator counter **339 → 351**; family count **20 → 21**. ## Indicators | Shape | Indicators | |-------|-----------| | Scalar (`f64`) | `SessionVwap`, `AverageDailyRange`, `OvernightGap`, `TurnOfMonth`, `SeasonalZScore` | | Struct | `SessionHighLow`, `SessionRange` (Asia/EU/US), `OvernightIntradayReturn` | | Profile (`Vec<f64>`) | `TimeOfDayReturnProfile`, `DayOfWeekProfile`, `IntradayVolatilityProfile`, `VolumeByTimeProfile` | ## Bindings The input is the **full** candle (`open, high, low, close, volume, timestamp`), not the `high/low/close` slice the value-indicator helper assumes, so the Python / Node / WASM bindings are custom full-candle implementations: - **Python** — `update((o,h,l,c,v,ts))`; `batch(open, high, low, close, volume, timestamp)` → `PyArray1` (scalar) / `PyArray2` (struct & profile), warmup rows `NaN`. - **Node** — `update(open, high, low, close, volume, timestamp)`; `batch(...)` → flat `Vec<f64>`; struct outputs as `#[napi(object)]` values. - **WASM** — `update` only (multi-input precedent); profiles as `Float64Array`, structs as camelCase objects, `timestamp` as `BigInt`. ## Verification - `wickra-core`: full per-branch unit tests, **100%** coverage target; 2852 lib tests + 334 doctests green. - `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean. - Node: 428 tests (dedicated `seasonality.test.js` streaming-vs-batch). - Python: full suite + dedicated `test_seasonality.py` streaming-vs-batch. - Counter check: mod-count == counted lib block == 351.
204 lines
7.0 KiB
Rust
204 lines
7.0 KiB
Rust
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
|
|
//!
|
|
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
|
|
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
|
|
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
|
|
//! relevant exchange session rather than UTC. This module turns an epoch
|
|
//! millisecond instant into its civil fields using Howard Hinnant's
|
|
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
|
|
//!
|
|
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
|
|
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
|
|
|
|
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct CivilTime {
|
|
/// Proleptic Gregorian year (can be negative for instants before year 1).
|
|
pub(crate) year: i64,
|
|
/// Month of year, `1..=12`.
|
|
pub(crate) month: u32,
|
|
/// Day of month, `1..=31`.
|
|
pub(crate) day: u32,
|
|
/// Hour of day, `0..=23`.
|
|
pub(crate) hour: u32,
|
|
/// Minute of hour, `0..=59`.
|
|
pub(crate) minute: u32,
|
|
/// Day of week with Monday as `0` through Sunday as `6`.
|
|
pub(crate) weekday: u32,
|
|
}
|
|
|
|
impl CivilTime {
|
|
/// Minute of day, `0..=1439`.
|
|
pub(crate) const fn minute_of_day(&self) -> u32 {
|
|
self.hour * 60 + self.minute
|
|
}
|
|
}
|
|
|
|
/// Decompose an epoch-millisecond instant into local civil fields.
|
|
///
|
|
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
|
|
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
|
|
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
|
|
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
|
|
let days = local_secs.div_euclid(86_400);
|
|
let secs_of_day = local_secs.rem_euclid(86_400);
|
|
let hour = (secs_of_day / 3600) as u32;
|
|
let minute = ((secs_of_day % 3600) / 60) as u32;
|
|
let (year, month, day) = civil_from_days(days);
|
|
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
|
|
let weekday = (days + 3).rem_euclid(7) as u32;
|
|
CivilTime {
|
|
year,
|
|
month,
|
|
day,
|
|
hour,
|
|
minute,
|
|
weekday,
|
|
}
|
|
}
|
|
|
|
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
|
|
///
|
|
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
|
|
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
let z = z + 719_468;
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
let doe = z - era * 146_097; // [0, 146096]
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
|
let year = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
|
let mp = (5 * doy + 2) / 153; // [0, 11]
|
|
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
|
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
|
(if month <= 2 { year + 1 } else { year }, month, day)
|
|
}
|
|
|
|
/// Whether `year` is a Gregorian leap year.
|
|
pub(crate) const fn is_leap(year: i64) -> bool {
|
|
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
}
|
|
|
|
/// Number of days in `month` (`1..=12`) of `year`.
|
|
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
|
|
match month {
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
4 | 6 | 9 | 11 => 30,
|
|
_ => {
|
|
if is_leap(year) {
|
|
29
|
|
} else {
|
|
28
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn epoch_zero_is_thursday_midnight() {
|
|
let t = civil_from_timestamp(0, 0);
|
|
assert_eq!(
|
|
t,
|
|
CivilTime {
|
|
year: 1970,
|
|
month: 1,
|
|
day: 1,
|
|
hour: 0,
|
|
minute: 0,
|
|
weekday: 3, // Thursday
|
|
}
|
|
);
|
|
assert_eq!(t.minute_of_day(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn known_utc_instant_mid_year() {
|
|
// 2021-06-15 13:45:00 UTC = 1623764700 s.
|
|
let t = civil_from_timestamp(1_623_764_700_000, 0);
|
|
assert_eq!(t.year, 2021);
|
|
assert_eq!(t.month, 6);
|
|
assert_eq!(t.day, 15);
|
|
assert_eq!(t.hour, 13);
|
|
assert_eq!(t.minute, 45);
|
|
assert_eq!(t.weekday, 1); // Tuesday
|
|
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
|
|
}
|
|
|
|
#[test]
|
|
fn new_year_2021_is_friday() {
|
|
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
|
|
let t = civil_from_timestamp(1_609_459_200_000, 0);
|
|
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
|
|
assert_eq!(t.weekday, 4); // Friday
|
|
}
|
|
|
|
#[test]
|
|
fn positive_offset_rolls_to_next_day() {
|
|
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
|
|
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
|
|
let t = civil_from_timestamp(base, 60);
|
|
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
|
|
assert_eq!((t.hour, t.minute), (0, 30));
|
|
assert_eq!(t.weekday, 5); // Saturday
|
|
}
|
|
|
|
#[test]
|
|
fn negative_offset_rolls_to_previous_day() {
|
|
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
|
|
let base = 1_609_459_200_000 + 30 * 60 * 1000;
|
|
let t = civil_from_timestamp(base, -60);
|
|
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
|
|
assert_eq!((t.hour, t.minute), (23, 30));
|
|
assert_eq!(t.weekday, 3); // Thursday
|
|
}
|
|
|
|
#[test]
|
|
fn sub_epoch_millis_floor_correctly() {
|
|
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
|
|
let t = civil_from_timestamp(-1, 0);
|
|
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
|
|
assert_eq!((t.hour, t.minute), (23, 59));
|
|
assert_eq!(t.weekday, 2); // Wednesday
|
|
}
|
|
|
|
#[test]
|
|
fn far_negative_day_count_hits_pre_era_branch() {
|
|
// A day count below -719468 drives `z + 719468` negative, exercising the
|
|
// `z - 146096` era branch in civil_from_days (year < 1).
|
|
let (year, month, day) = civil_from_days(-1_000_000);
|
|
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
|
|
// Gregorian, astronomical year numbering where year 0 exists).
|
|
assert_eq!((year, month, day), (-768, 2, 4));
|
|
}
|
|
|
|
#[test]
|
|
fn leap_year_rules() {
|
|
assert!(is_leap(2000));
|
|
assert!(!is_leap(1900));
|
|
assert!(is_leap(2024));
|
|
assert!(!is_leap(2023));
|
|
}
|
|
|
|
#[test]
|
|
fn days_in_month_all_cases() {
|
|
assert_eq!(days_in_month(2023, 1), 31);
|
|
assert_eq!(days_in_month(2023, 4), 30);
|
|
assert_eq!(days_in_month(2023, 2), 28);
|
|
assert_eq!(days_in_month(2024, 2), 29);
|
|
assert_eq!(days_in_month(2023, 12), 31);
|
|
assert_eq!(days_in_month(2023, 11), 30);
|
|
}
|
|
|
|
#[test]
|
|
fn leap_day_decodes() {
|
|
// 2024-02-29 12:00 UTC.
|
|
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
|
|
let t = civil_from_timestamp(secs * 1000, 0);
|
|
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
|
|
assert_eq!(t.hour, 12);
|
|
}
|
|
}
|