xbbg_core 1.4.7

Safe Rust wrappers for BLPAPI session, request, and response primitives
//! High-precision datetime type
//!
//! Wrapper around Bloomberg's 16-byte packed datetime structure.
//! Converts to microseconds/nanoseconds since Unix epoch using pure arithmetic.

use crate::ffi;

/// Bloomberg high-precision datetime.
///
/// Wrapper around FFI type with conversion methods.
/// Timestamp conversions honor Bloomberg's `OFFSET` part when it is set.
///
/// # Examples
///
/// ```ignore
/// // From Bloomberg response
/// let dt = element.get_datetime(0).unwrap();
/// let micros = dt.to_micros();  // Microseconds since Unix epoch
/// let nanos = dt.to_nanos();    // Nanoseconds since Unix epoch
/// ```
///
/// # Size
/// Guaranteed to be 16 bytes (verified at compile time in ffi.rs).
#[repr(transparent)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HighPrecisionDatetime(pub(crate) ffi::blpapi_HighPrecisionDatetime_t);

impl HighPrecisionDatetime {
    /// Create from raw FFI type (for testing/benchmarking).
    ///
    /// # Safety
    /// Caller must ensure the FFI type is properly initialized.
    #[doc(hidden)]
    #[inline(always)]
    pub const fn from_raw(raw: ffi::blpapi_HighPrecisionDatetime_t) -> Self {
        Self(raw)
    }

    /// Access raw FFI type.
    ///
    /// Provides direct access to the underlying Bloomberg datetime structure.
    #[inline(always)]
    pub fn raw(&self) -> &ffi::blpapi_HighPrecisionDatetime_t {
        &self.0
    }

    /// Convert to UTC microseconds since Unix epoch.
    ///
    /// Bloomberg's offset is minutes ahead of UTC. When the `OFFSET` part is
    /// present, this subtracts that offset so the returned epoch value is UTC.
    /// Datetimes without an offset are treated as already UTC.
    ///
    /// # Performance
    /// Pure arithmetic, no allocations. Target: < 20ns.
    #[inline(always)]
    pub fn to_micros(&self) -> i64 {
        let days = days_from_ymd(self.0.year as i32, self.0.month as u32, self.0.day as u32);
        let us = self.to_time_micros();
        days * 86_400_000_000 + us - self.offset_micros()
    }

    /// Convert to microseconds from midnight (time-of-day only).
    ///
    /// Ignores date parts entirely — only uses hours, minutes, seconds,
    /// milliseconds, and picoseconds. Use for Bloomberg `Time`-only fields
    /// where the date parts are zeroed.
    ///
    /// Returns a value in the range [0, 86_400_000_000) for valid times.
    ///
    /// # Performance
    /// Pure arithmetic, no allocations. Target: < 10ns.
    #[inline(always)]
    pub fn to_time_micros(&self) -> i64 {
        (self.0.hours as i64) * 3_600_000_000
            + (self.0.minutes as i64) * 60_000_000
            + (self.0.seconds as i64) * 1_000_000
            + (self.0.milliseconds as i64) * 1_000
            + (self.0.picoseconds as i64) / 1_000_000
    }

    /// Check if date parts (year, month, day) are all present.
    ///
    /// Bloomberg `Datetime` fields sometimes have zeroed date parts — the
    /// `parts` bitmask tells us which components are actually valid.
    /// When date parts are missing, `to_micros()` produces garbage.
    #[inline(always)]
    pub fn has_date_parts(&self) -> bool {
        (self.0.parts & ffi::BLPAPI_DATETIME_DATE_PART) == ffi::BLPAPI_DATETIME_DATE_PART
    }

    /// Check if the timezone offset part is present.
    #[inline(always)]
    pub fn has_offset_part(&self) -> bool {
        (self.0.parts & ffi::BLPAPI_DATETIME_OFFSET_PART) == ffi::BLPAPI_DATETIME_OFFSET_PART
    }

    #[inline(always)]
    fn offset_micros(&self) -> i64 {
        if self.has_offset_part() {
            (self.0.offset as i64) * 60_000_000
        } else {
            0
        }
    }

    #[inline(always)]
    fn offset_nanos(&self) -> i64 {
        if self.has_offset_part() {
            (self.0.offset as i64) * 60_000_000_000
        } else {
            0
        }
    }

    /// Convert to UTC nanoseconds since Unix epoch.
    ///
    /// Bloomberg's offset is minutes ahead of UTC. When the `OFFSET` part is
    /// present, this subtracts that offset so the returned epoch value is UTC.
    /// Datetimes without an offset are treated as already UTC.
    ///
    /// # Performance
    /// Pure arithmetic, no allocations. Target: < 20ns.
    #[inline(always)]
    pub fn to_nanos(&self) -> i64 {
        let days = days_from_ymd(self.0.year as i32, self.0.month as u32, self.0.day as u32);
        let ns = (self.0.hours as i64) * 3_600_000_000_000
            + (self.0.minutes as i64) * 60_000_000_000
            + (self.0.seconds as i64) * 1_000_000_000
            + (self.0.milliseconds as i64) * 1_000_000
            + (self.0.picoseconds as i64) / 1_000;
        days * 86_400_000_000_000 + ns - self.offset_nanos()
    }
}

/// Days since Unix epoch. Branchless algorithm (Howard Hinnant).
///
/// This is a well-known civil time algorithm that avoids lookups and branches.
#[inline(always)]
fn days_from_ymd(y: i32, m: u32, d: u32) -> i64 {
    let y = y as i64 - (m <= 2) as i64;
    let era = y.div_euclid(400);
    let yoe = y.rem_euclid(400) as u32;
    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146097 + doe as i64 - 719468
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_datetime(
        year: u16,
        month: u8,
        day: u8,
        hours: u8,
        minutes: u8,
        seconds: u8,
        milliseconds: u16,
    ) -> HighPrecisionDatetime {
        HighPrecisionDatetime(ffi::blpapi_HighPrecisionDatetime_t {
            parts: 0xF7,
            hours,
            minutes,
            seconds,
            milliseconds,
            month,
            day,
            year,
            offset: 0,
            picoseconds: 0,
        })
    }

    #[test]
    fn test_datetime_size() {
        // Verify both FFI type and wrapper are 16 bytes
        assert_eq!(
            std::mem::size_of::<ffi::blpapi_HighPrecisionDatetime_t>(),
            16
        );
        assert_eq!(std::mem::size_of::<HighPrecisionDatetime>(), 16);
    }

    #[test]
    fn test_unix_epoch() {
        // 1970-01-01 00:00:00.000 -> 0
        let dt = make_datetime(1970, 1, 1, 0, 0, 0, 0);
        assert_eq!(dt.to_micros(), 0);
    }

    #[test]
    fn test_datetime_conversion() {
        // 2024-06-15 14:30:45.123
        // Using Howard Hinnant's algorithm (well-tested civil time conversion)
        let dt = make_datetime(2024, 6, 15, 14, 30, 45, 123);
        assert_eq!(dt.to_micros(), 1718461845123000);
    }

    #[test]
    fn test_y2k() {
        // 2000-01-01 00:00:00.000 -> 946684800000000
        let dt = make_datetime(2000, 1, 1, 0, 0, 0, 0);
        assert_eq!(dt.to_micros(), 946684800000000);
    }

    #[test]
    fn test_to_nanos() {
        // Verify nanosecond conversion
        let dt = make_datetime(1970, 1, 1, 0, 0, 1, 0);
        assert_eq!(dt.to_nanos(), 1_000_000_000); // 1 second in nanoseconds
    }

    #[test]
    fn test_to_micros_applies_positive_offset() {
        let mut dt = make_datetime(1970, 1, 1, 1, 0, 0, 0);
        dt.0.parts |= ffi::BLPAPI_DATETIME_OFFSET_PART;
        dt.0.offset = 60;
        assert_eq!(dt.to_micros(), 0);
    }

    #[test]
    fn test_to_micros_applies_negative_offset() {
        let mut dt = make_datetime(1970, 1, 1, 0, 0, 0, 0);
        dt.0.parts |= ffi::BLPAPI_DATETIME_OFFSET_PART;
        dt.0.offset = -60;
        assert_eq!(dt.to_micros(), 3_600_000_000);
    }

    #[test]
    fn test_to_nanos_applies_offset() {
        let mut dt = make_datetime(1970, 1, 1, 1, 0, 0, 0);
        dt.0.parts |= ffi::BLPAPI_DATETIME_OFFSET_PART;
        dt.0.offset = 60;
        assert_eq!(dt.to_nanos(), 0);
    }
}