Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
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}