repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

5.9 KB · 153 lines · Rust Raw History
  1//! US equity market session clock.
  2//!
  3//! Anything that depends on "is the market open" goes through here. Hours are
  4//! evaluated in `America/New_York` (the exchange's wall clock), so the
  5//! daylight-saving shift is handled by `chrono-tz` rather than by us.
  6//!
  7//! Holidays are deliberately NOT modelled: a full exchange-holiday calendar
  8//! would need yearly upkeep, and getting it wrong costs almost nothing here.
  9//! On a holiday the demand-driven intraday job just polls a flat market (and
 10//! only if someone is watching), and the daily-close job fetches one unchanged
 11//! quote per symbol. Neither risks a rate limit or stores bad data.
 12
 13use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
 14use chrono_tz::America::New_York;
 15
 16/// A point in the US equity trading day.
 17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 18pub enum Session {
 19    /// Outside all trading hours: overnight or weekend.
 20    Closed,
 21    /// Pre-market, 04:00–09:30 ET.
 22    Pre,
 23    /// Regular session, 09:30–16:00 ET.
 24    Regular,
 25    /// After-hours, 16:00–20:00 ET.
 26    Post,
 27}
 28
 29impl Session {
 30    /// Whether any trading session (pre, regular, or post) is in progress.
 31    pub fn is_open(self) -> bool {
 32        !matches!(self, Session::Closed)
 33    }
 34
 35    /// A stable lowercase token for the SSE `market` event and the status pill.
 36    pub fn as_str(self) -> &'static str {
 37        match self {
 38            Session::Closed => "closed",
 39            Session::Pre => "pre",
 40            Session::Regular => "regular",
 41            Session::Post => "post",
 42        }
 43    }
 44}
 45
 46fn at(h: u32, m: u32) -> NaiveTime {
 47    NaiveTime::from_hms_opt(h, m, 0).expect("valid wall-clock time")
 48}
 49
 50/// The trading session in effect at `now`.
 51pub fn session_at(now: DateTime<Utc>) -> Session {
 52    let et = now.with_timezone(&New_York);
 53    if matches!(et.weekday(), Weekday::Sat | Weekday::Sun) {
 54        return Session::Closed;
 55    }
 56    let t = et.time();
 57    if t >= at(9, 30) && t < at(16, 0) {
 58        Session::Regular
 59    } else if t >= at(4, 0) && t < at(9, 30) {
 60        Session::Pre
 61    } else if t >= at(16, 0) && t < at(20, 0) {
 62        Session::Post
 63    } else {
 64        Session::Closed
 65    }
 66}
 67
 68/// The share of a full trading day's volume that should have accumulated by
 69/// `now`, for proration. During the regular session it is the fraction of the
 70/// 09:30–16:00 ET session elapsed (floored at 0.02 so the first minutes do not
 71/// divide by ~0); at every other time it is 1.0, because Yahoo's
 72/// `regularMarketVolume` then reflects a *complete* session (the prior day's in
 73/// pre-market, today's after the close). Dividing today's cumulative volume by
 74/// `avg_full_day * this_fraction` compares it to the volume typically seen by
 75/// this point in the day, instead of reading "light" all morning.
 76pub fn volume_session_fraction(now: DateTime<Utc>) -> f64 {
 77    match session_at(now) {
 78        Session::Regular => {
 79            let t = now.with_timezone(&New_York).time();
 80            let elapsed = (t - at(9, 30)).num_seconds() as f64;
 81            let total = (at(16, 0) - at(9, 30)).num_seconds() as f64;
 82            (elapsed / total).clamp(0.02, 1.0)
 83        }
 84        _ => 1.0,
 85    }
 86}
 87
 88/// The `America/New_York` calendar date (`YYYY-MM-DD`) at `now`.
 89// Retained past the Phase-A removal of the daily-close job: the Phase-C
 90// dashboard resolves "today" / the most-recent trading day for the day graph.
 91#[allow(dead_code)]
 92pub fn et_date(now: DateTime<Utc>) -> String {
 93    now.with_timezone(&New_York).format("%Y-%m-%d").to_string()
 94}
 95
 96/// Whether `now` falls on a weekday in ET (no holiday calendar; see the
 97/// module note).
 98#[allow(dead_code)] // see et_date: Phase-C market-hours logic.
 99pub fn is_et_weekday(now: DateTime<Utc>) -> bool {
100    !matches!(
101        now.with_timezone(&New_York).weekday(),
102        Weekday::Sat | Weekday::Sun
103    )
104}
105
106/// Whether the regular session has closed for the current ET day: time is at
107/// or past 16:05 ET.
108#[allow(dead_code)] // see et_date: Phase-C market-hours logic.
109pub fn after_close(now: DateTime<Utc>) -> bool {
110    now.with_timezone(&New_York).time() >= at(16, 5)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use chrono::TimeZone;
117
118    // June 2026 is EDT (UTC-4), so ET = UTC - 4h. 2026-06-24 is a Wednesday.
119    fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
120        Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
121    }
122
123    #[test]
124    fn session_at_maps_the_trading_day() {
125        assert_eq!(session_at(utc(2026, 6, 24, 12, 0)), Session::Pre); // 08:00 ET
126        assert_eq!(session_at(utc(2026, 6, 24, 13, 30)), Session::Regular); // 09:30 ET open
127        assert_eq!(session_at(utc(2026, 6, 24, 17, 0)), Session::Regular); // 13:00 ET
128        assert_eq!(session_at(utc(2026, 6, 24, 20, 0)), Session::Post); // 16:00 ET close
129        assert_eq!(session_at(utc(2026, 6, 24, 1, 0)), Session::Closed); // overnight
130        assert_eq!(session_at(utc(2026, 6, 27, 17, 0)), Session::Closed); // Saturday
131    }
132
133    #[test]
134    fn volume_fraction_prorates_only_during_the_regular_session() {
135        // Pre-market: regularMarketVolume is the prior full session → 1.0.
136        assert_eq!(volume_session_fraction(utc(2026, 6, 24, 12, 0)), 1.0);
137        // Midday (13:00 ET): 3.5h of a 6.5h session elapsed ≈ 0.538.
138        let mid = volume_session_fraction(utc(2026, 6, 24, 17, 0));
139        assert!((mid - 3.5 / 6.5).abs() < 1e-6, "midday fraction was {mid}");
140        // After hours and weekends are a complete session → 1.0.
141        assert_eq!(volume_session_fraction(utc(2026, 6, 24, 21, 0)), 1.0);
142        assert_eq!(volume_session_fraction(utc(2026, 6, 27, 17, 0)), 1.0);
143    }
144
145    #[test]
146    fn volume_fraction_floors_at_the_open() {
147        // Right at the open the elapsed fraction is floored (not ~0) so the
148        // morning ratio does not divide by near-zero and explode.
149        let at_open = volume_session_fraction(utc(2026, 6, 24, 13, 30));
150        assert!(at_open >= 0.02, "expected a floor, got {at_open}");
151    }
152}