repos
5.9 KB raw
//! US equity market session clock.
//!
//! Anything that depends on "is the market open" goes through here. Hours are
//! evaluated in `America/New_York` (the exchange's wall clock), so the
//! daylight-saving shift is handled by `chrono-tz` rather than by us.
//!
//! Holidays are deliberately NOT modelled: a full exchange-holiday calendar
//! would need yearly upkeep, and getting it wrong costs almost nothing here.
//! On a holiday the demand-driven intraday job just polls a flat market (and
//! only if someone is watching), and the daily-close job fetches one unchanged
//! quote per symbol. Neither risks a rate limit or stores bad data.

use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
use chrono_tz::America::New_York;

/// A point in the US equity trading day.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Session {
    /// Outside all trading hours: overnight or weekend.
    Closed,
    /// Pre-market, 04:00–09:30 ET.
    Pre,
    /// Regular session, 09:30–16:00 ET.
    Regular,
    /// After-hours, 16:00–20:00 ET.
    Post,
}

impl Session {
    /// Whether any trading session (pre, regular, or post) is in progress.
    pub fn is_open(self) -> bool {
        !matches!(self, Session::Closed)
    }

    /// A stable lowercase token for the SSE `market` event and the status pill.
    pub fn as_str(self) -> &'static str {
        match self {
            Session::Closed => "closed",
            Session::Pre => "pre",
            Session::Regular => "regular",
            Session::Post => "post",
        }
    }
}

fn at(h: u32, m: u32) -> NaiveTime {
    NaiveTime::from_hms_opt(h, m, 0).expect("valid wall-clock time")
}

/// The trading session in effect at `now`.
pub fn session_at(now: DateTime<Utc>) -> Session {
    let et = now.with_timezone(&New_York);
    if matches!(et.weekday(), Weekday::Sat | Weekday::Sun) {
        return Session::Closed;
    }
    let t = et.time();
    if t >= at(9, 30) && t < at(16, 0) {
        Session::Regular
    } else if t >= at(4, 0) && t < at(9, 30) {
        Session::Pre
    } else if t >= at(16, 0) && t < at(20, 0) {
        Session::Post
    } else {
        Session::Closed
    }
}

/// The share of a full trading day's volume that should have accumulated by
/// `now`, for proration. During the regular session it is the fraction of the
/// 09:30–16:00 ET session elapsed (floored at 0.02 so the first minutes do not
/// divide by ~0); at every other time it is 1.0, because Yahoo's
/// `regularMarketVolume` then reflects a *complete* session (the prior day's in
/// pre-market, today's after the close). Dividing today's cumulative volume by
/// `avg_full_day * this_fraction` compares it to the volume typically seen by
/// this point in the day, instead of reading "light" all morning.
pub fn volume_session_fraction(now: DateTime<Utc>) -> f64 {
    match session_at(now) {
        Session::Regular => {
            let t = now.with_timezone(&New_York).time();
            let elapsed = (t - at(9, 30)).num_seconds() as f64;
            let total = (at(16, 0) - at(9, 30)).num_seconds() as f64;
            (elapsed / total).clamp(0.02, 1.0)
        }
        _ => 1.0,
    }
}

/// The `America/New_York` calendar date (`YYYY-MM-DD`) at `now`.
// Retained past the Phase-A removal of the daily-close job: the Phase-C
// dashboard resolves "today" / the most-recent trading day for the day graph.
#[allow(dead_code)]
pub fn et_date(now: DateTime<Utc>) -> String {
    now.with_timezone(&New_York).format("%Y-%m-%d").to_string()
}

/// Whether `now` falls on a weekday in ET (no holiday calendar; see the
/// module note).
#[allow(dead_code)] // see et_date: Phase-C market-hours logic.
pub fn is_et_weekday(now: DateTime<Utc>) -> bool {
    !matches!(
        now.with_timezone(&New_York).weekday(),
        Weekday::Sat | Weekday::Sun
    )
}

/// Whether the regular session has closed for the current ET day: time is at
/// or past 16:05 ET.
#[allow(dead_code)] // see et_date: Phase-C market-hours logic.
pub fn after_close(now: DateTime<Utc>) -> bool {
    now.with_timezone(&New_York).time() >= at(16, 5)
}

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

    // June 2026 is EDT (UTC-4), so ET = UTC - 4h. 2026-06-24 is a Wednesday.
    fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
        Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
    }

    #[test]
    fn session_at_maps_the_trading_day() {
        assert_eq!(session_at(utc(2026, 6, 24, 12, 0)), Session::Pre); // 08:00 ET
        assert_eq!(session_at(utc(2026, 6, 24, 13, 30)), Session::Regular); // 09:30 ET open
        assert_eq!(session_at(utc(2026, 6, 24, 17, 0)), Session::Regular); // 13:00 ET
        assert_eq!(session_at(utc(2026, 6, 24, 20, 0)), Session::Post); // 16:00 ET close
        assert_eq!(session_at(utc(2026, 6, 24, 1, 0)), Session::Closed); // overnight
        assert_eq!(session_at(utc(2026, 6, 27, 17, 0)), Session::Closed); // Saturday
    }

    #[test]
    fn volume_fraction_prorates_only_during_the_regular_session() {
        // Pre-market: regularMarketVolume is the prior full session → 1.0.
        assert_eq!(volume_session_fraction(utc(2026, 6, 24, 12, 0)), 1.0);
        // Midday (13:00 ET): 3.5h of a 6.5h session elapsed ≈ 0.538.
        let mid = volume_session_fraction(utc(2026, 6, 24, 17, 0));
        assert!((mid - 3.5 / 6.5).abs() < 1e-6, "midday fraction was {mid}");
        // After hours and weekends are a complete session → 1.0.
        assert_eq!(volume_session_fraction(utc(2026, 6, 24, 21, 0)), 1.0);
        assert_eq!(volume_session_fraction(utc(2026, 6, 27, 17, 0)), 1.0);
    }

    #[test]
    fn volume_fraction_floors_at_the_open() {
        // Right at the open the elapsed fraction is floored (not ~0) so the
        // morning ratio does not divide by near-zero and explode.
        let at_open = volume_session_fraction(utc(2026, 6, 24, 13, 30));
        assert!(at_open >= 0.02, "expected a floor, got {at_open}");
    }
}