use std::collections::HashMap;
use chrono::NaiveDate;
use serde::Deserialize;
use crate::client::SchwabClient;
use crate::error::Result;
use crate::macros::string_enum;
#[derive(Debug)]
pub struct MarketHours<'a> {
client: &'a SchwabClient,
}
impl<'a> MarketHours<'a> {
pub(crate) fn new(client: &'a SchwabClient) -> Self {
Self { client }
}
pub fn list<I>(&self, markets: I) -> ListMarketHoursBuilder<'a>
where
I: IntoIterator<Item = Market>,
{
let csv = markets
.into_iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(",");
ListMarketHoursBuilder {
client: self.client,
markets: csv,
date: None,
}
}
pub fn get(&self, market: Market) -> GetMarketHoursBuilder<'a> {
GetMarketHoursBuilder {
client: self.client,
market,
date: None,
}
}
}
#[derive(Debug)]
#[must_use = "call .send() to execute the request"]
pub struct ListMarketHoursBuilder<'a> {
client: &'a SchwabClient,
markets: String,
date: Option<NaiveDate>,
}
impl<'a> ListMarketHoursBuilder<'a> {
pub fn date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
pub async fn send(self) -> Result<MarketHoursResponse> {
let mut request = self
.client
.market_data_http()
.get("/markets")
.query(&[("markets", self.markets.as_str())]);
if let Some(d) = self.date {
let s = d.format("%Y-%m-%d").to_string();
request = request.query(&[("date", s.as_str())]);
}
request.send_json().await
}
}
#[derive(Debug)]
#[must_use = "call .send() to execute the request"]
pub struct GetMarketHoursBuilder<'a> {
client: &'a SchwabClient,
market: Market,
date: Option<NaiveDate>,
}
impl<'a> GetMarketHoursBuilder<'a> {
pub fn date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
pub async fn send(self) -> Result<MarketHoursResponse> {
let path = format!("/markets/{}", self.market);
let mut request = self.client.market_data_http().get(&path);
if let Some(d) = self.date {
let s = d.format("%Y-%m-%d").to_string();
request = request.query(&[("date", s.as_str())]);
}
request.send_json().await
}
}
pub type MarketHoursResponse = HashMap<String, HashMap<String, Hours>>;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct Hours {
#[serde(default)]
pub date: Option<String>,
#[serde(rename = "marketType", default)]
pub market_type: Option<MarketType>,
#[serde(default)]
pub exchange: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub product: Option<String>,
#[serde(rename = "productName", default)]
pub product_name: Option<String>,
#[serde(rename = "isOpen", default)]
pub is_open: Option<bool>,
#[serde(rename = "sessionHours", default)]
pub session_hours: HashMap<String, Vec<Interval>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Interval {
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub end: Option<String>,
}
string_enum! {
Market {
Equity = "equity",
Option_ = "option",
Bond = "bond",
Future = "future",
Forex = "forex",
}
}
string_enum! {
MarketType {
Bond = "BOND",
Equity = "EQUITY",
Etf = "ETF",
Extended = "EXTENDED",
Forex = "FOREX",
Future = "FUTURE",
FutureOption = "FUTURE_OPTION",
Fundamental = "FUNDAMENTAL",
Index = "INDEX",
Indicator = "INDICATOR",
MutualFund = "MUTUAL_FUND",
Option_ = "OPTION",
UnknownSchwab = "UNKNOWN",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equity_market_hours_response_parses() {
let json = r#"{
"equity": {
"EQ": {
"date": "2024-03-15",
"marketType": "EQUITY",
"exchange": "NULL",
"category": "NULL",
"product": "EQ",
"productName": "equity",
"isOpen": true,
"sessionHours": {
"preMarket": [
{ "start": "2024-03-15T07:00:00-04:00", "end": "2024-03-15T09:30:00-04:00" }
],
"regularMarket": [
{ "start": "2024-03-15T09:30:00-04:00", "end": "2024-03-15T16:00:00-04:00" }
],
"postMarket": [
{ "start": "2024-03-15T16:00:00-04:00", "end": "2024-03-15T20:00:00-04:00" }
]
}
}
}
}"#;
let resp: MarketHoursResponse = serde_json::from_str(json).unwrap();
let equity = resp.get("equity").unwrap();
let eq = equity.get("EQ").unwrap();
assert_eq!(eq.date.as_deref(), Some("2024-03-15"));
assert_eq!(eq.market_type, Some(MarketType::Equity));
assert_eq!(eq.is_open, Some(true));
assert_eq!(eq.product.as_deref(), Some("EQ"));
let regular = eq.session_hours.get("regularMarket").unwrap();
assert_eq!(regular.len(), 1);
assert_eq!(
regular[0].start.as_deref(),
Some("2024-03-15T09:30:00-04:00")
);
assert_eq!(regular[0].end.as_deref(), Some("2024-03-15T16:00:00-04:00"));
let pre = eq.session_hours.get("preMarket").unwrap();
assert_eq!(pre[0].end.as_deref(), Some("2024-03-15T09:30:00-04:00"));
}
#[test]
fn closed_market_response_parses() {
let json = r#"{
"equity": {
"EQ": {
"date": "2024-12-25",
"marketType": "EQUITY",
"product": "EQ",
"isOpen": false,
"sessionHours": {}
}
}
}"#;
let resp: MarketHoursResponse = serde_json::from_str(json).unwrap();
let eq = resp.get("equity").unwrap().get("EQ").unwrap();
assert_eq!(eq.is_open, Some(false));
assert!(eq.session_hours.is_empty());
}
#[test]
fn multi_market_response_parses() {
let json = r#"{
"equity": { "EQ": { "product": "EQ", "isOpen": true, "sessionHours": {} } },
"option": { "EQO": { "product": "EQO", "isOpen": true, "sessionHours": {} } }
}"#;
let resp: MarketHoursResponse = serde_json::from_str(json).unwrap();
assert!(resp.contains_key("equity"));
assert!(resp.contains_key("option"));
}
#[test]
fn market_round_trips_known_variants() {
for raw in ["equity", "option", "bond", "future", "forex"] {
let json = format!(r#""{raw}""#);
let parsed: Market = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
}
}
#[test]
fn unknown_market_type_preserves_raw_string() {
let parsed: MarketType = serde_json::from_str(r#""NEW_CLASS""#).unwrap();
assert!(matches!(parsed, MarketType::Unknown(ref s) if s == "NEW_CLASS"));
}
#[test]
fn naive_date_formats_to_schwab_wire_form() {
let d = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
assert_eq!(d.format("%Y-%m-%d").to_string(), "2024-03-15");
}
}