schwab_cli/
market_hours.rs1use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
4use chrono_tz::America::New_York;
5use serde_json::Value;
6
7use crate::agent::state::AgentState;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum MarketStatusSource {
12 SchwabApi,
13 HoursCache,
14 Agent,
15 Schedule,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ResolvedMarketStatus {
21 pub open: bool,
22 pub source: MarketStatusSource,
23}
24
25impl ResolvedMarketStatus {
26 pub fn label(self, agent_session: Option<&str>) -> String {
27 let base = if self.open {
28 "OPEN (regular)".to_string()
29 } else if matches!(agent_session, Some("overnight")) {
30 "CLOSED (overnight)".to_string()
31 } else {
32 "CLOSED".to_string()
33 };
34 match self.source {
35 MarketStatusSource::Schedule => format!("{base} (est)"),
36 _ => base,
37 }
38 }
39}
40
41pub fn eqo_regular_session_open(hours: &Value, now: DateTime<Utc>) -> Option<bool> {
46 let windows = eqo_regular_windows(hours)?;
47 Some(in_any_window(&windows, now))
48}
49
50fn eqo_regular_windows(
51 hours: &Value,
52) -> Option<Vec<(DateTime<chrono::FixedOffset>, DateTime<chrono::FixedOffset>)>> {
53 let raw = hours
54 .pointer("/option/EQO/sessionHours/regularMarket")
55 .or_else(|| hours.pointer("/option/option/sessionHours/regularMarket"))?
56 .as_array()?;
57
58 let mut windows = Vec::new();
59 for w in raw {
60 let start_s = w.get("start")?.as_str()?;
61 let end_s = w.get("end")?.as_str()?;
62 let start = DateTime::parse_from_rfc3339(start_s).ok()?;
63 let end = DateTime::parse_from_rfc3339(end_s).ok()?;
64 windows.push((start, end));
65 }
66 if windows.is_empty() {
67 None
68 } else {
69 Some(windows)
70 }
71}
72
73fn in_any_window(
74 windows: &[(DateTime<chrono::FixedOffset>, DateTime<chrono::FixedOffset>)],
75 now: DateTime<Utc>,
76) -> bool {
77 for (start, end) in windows {
78 let t = now.with_timezone(&start.timezone());
79 if t >= *start && t <= *end {
80 return true;
81 }
82 }
83 false
84}
85
86pub fn option_market_open_from_hours(hours: &Value, now: DateTime<Utc>) -> Option<bool> {
88 if let Some(open) = eqo_regular_session_open(hours, now) {
89 return Some(open);
90 }
91 hours
92 .pointer("/option/EQO/isOpen")
93 .or_else(|| hours.pointer("/option/option/isOpen"))
94 .and_then(|v| v.as_bool())
95}
96
97pub fn eqo_regular_session_estimate(now: DateTime<Utc>) -> bool {
99 let et = now.with_timezone(&New_York);
100 if matches!(et.weekday(), Weekday::Sat | Weekday::Sun) {
101 return false;
102 }
103 let Some(open) = NaiveTime::from_hms_opt(9, 30, 0) else {
104 return false;
105 };
106 let Some(close) = NaiveTime::from_hms_opt(16, 0, 0) else {
107 return false;
108 };
109 let t = et.time();
110 t >= open && t <= close
111}
112
113pub fn resolve_eqo_market_open(
115 hours: Option<&Value>,
116 state: &AgentState,
117 now: DateTime<Utc>,
118 hours_source: Option<MarketStatusSource>,
119) -> ResolvedMarketStatus {
120 if let (Some(h), Some(src)) = (hours, hours_source) {
121 if let Some(open) = option_market_open_from_hours(h, now) {
122 return ResolvedMarketStatus { open, source: src };
123 }
124 }
125
126 if let Some(open) = state.last_market_open {
127 if state.last_tick.is_some_and(|at| (now - at).num_hours() < 6) {
128 return ResolvedMarketStatus {
129 open,
130 source: MarketStatusSource::Agent,
131 };
132 }
133 }
134
135 if let (Some(session), Some(at)) = (state.last_session.as_deref(), state.last_tick) {
136 if (now - at).num_hours() < 3 {
137 let open = session == "regular";
138 return ResolvedMarketStatus {
139 open,
140 source: MarketStatusSource::Agent,
141 };
142 }
143 }
144
145 ResolvedMarketStatus {
146 open: eqo_regular_session_estimate(now),
147 source: MarketStatusSource::Schedule,
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use serde_json::json;
155
156 #[test]
157 fn regular_window_closed_after_4pm_et() {
158 let hours = json!({
159 "option": {
160 "EQO": {
161 "isOpen": true,
162 "sessionHours": {
163 "regularMarket": [{
164 "start": "2026-06-25T09:30:00-04:00",
165 "end": "2026-06-25T16:00:00-04:00"
166 }]
167 }
168 }
169 }
170 });
171 let after_close = DateTime::parse_from_rfc3339("2026-06-25T17:48:00-04:00")
172 .unwrap()
173 .with_timezone(&Utc);
174 assert_eq!(
175 option_market_open_from_hours(&hours, after_close),
176 Some(false)
177 );
178 }
179
180 #[test]
181 fn regular_window_open_midday_et() {
182 let hours = json!({
183 "option": {
184 "EQO": {
185 "isOpen": true,
186 "sessionHours": {
187 "regularMarket": [{
188 "start": "2026-06-25T09:30:00-04:00",
189 "end": "2026-06-25T16:00:00-04:00"
190 }]
191 }
192 }
193 }
194 });
195 let midday = DateTime::parse_from_rfc3339("2026-06-25T12:00:00-04:00")
196 .unwrap()
197 .with_timezone(&Utc);
198 assert_eq!(option_market_open_from_hours(&hours, midday), Some(true));
199 }
200
201 #[test]
202 fn pst_248pm_is_closed() {
203 let hours = json!({
204 "option": {
205 "EQO": {
206 "isOpen": true,
207 "sessionHours": {
208 "regularMarket": [{
209 "start": "2026-06-25T09:30:00-04:00",
210 "end": "2026-06-25T16:00:00-04:00"
211 }]
212 }
213 }
214 }
215 });
216 let pst = DateTime::parse_from_rfc3339("2026-06-25T14:48:00-07:00")
217 .unwrap()
218 .with_timezone(&Utc);
219 assert_eq!(option_market_open_from_hours(&hours, pst), Some(false));
220 }
221
222 #[test]
223 fn schedule_estimate_weekend_closed() {
224 let sat = DateTime::parse_from_rfc3339("2026-06-27T12:00:00-04:00")
225 .unwrap()
226 .with_timezone(&Utc);
227 assert!(!eqo_regular_session_estimate(sat));
228 }
229
230 #[test]
231 fn resolve_never_unknown_uses_schedule() {
232 let state = AgentState::default();
233 let status = resolve_eqo_market_open(None, &state, Utc::now(), None);
234 assert_eq!(status.source, MarketStatusSource::Schedule);
235 }
236
237 #[test]
238 fn resolve_prefers_cached_hours() {
239 let hours = json!({
240 "option": {
241 "EQO": {
242 "sessionHours": {
243 "regularMarket": [{
244 "start": "2026-06-25T09:30:00-04:00",
245 "end": "2026-06-25T16:00:00-04:00"
246 }]
247 }
248 }
249 }
250 });
251 let after_close = DateTime::parse_from_rfc3339("2026-06-25T17:00:00-04:00")
252 .unwrap()
253 .with_timezone(&Utc);
254 let status = resolve_eqo_market_open(
255 Some(&hours),
256 &AgentState::default(),
257 after_close,
258 Some(MarketStatusSource::HoursCache),
259 );
260 assert!(!status.open);
261 assert_eq!(status.source, MarketStatusSource::HoursCache);
262 }
263}