wickra-core 0.5.5

Core streaming-first technical indicators engine for the Wickra library
Documentation
//! 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);
    }
}