Skip to main content

schwab_cli/
market_hours.rs

1//! Interpret Schwab `/markets` hours responses for session-aware open/closed checks.
2
3use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
4use chrono_tz::America::New_York;
5use serde_json::Value;
6
7use crate::agent::state::AgentState;
8
9/// Where the displayed market status came from.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum MarketStatusSource {
12    SchwabApi,
13    HoursCache,
14    Agent,
15    Schedule,
16}
17
18/// Resolved EQO regular-session status (never "unknown").
19#[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
41/// Whether equity options (EQO) are in **regular** session right now.
42///
43/// Schwab's `isOpen` flag can stay `true` outside regular hours (e.g. after 4:00 PM ET
44/// on a trading day). Prefer `sessionHours.regularMarket` windows when present.
45pub 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
86/// Best-effort EQO regular-session open flag from a Schwab hours payload.
87pub 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
97/// Weekday EQO regular hours in US Eastern (9:30–16:00). Does not model exchange holidays.
98pub 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
113/// Resolve EQO status: live/cached Schwab hours → recent agent state → ET schedule estimate.
114pub 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}