use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
struct RawHoursResponse {
finance: RawFinance,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawFinance {
#[serde(default)]
market_times: Vec<RawMarketTimes>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMarketTimes {
#[serde(default)]
market_time: Vec<RawMarketTime>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMarketTime {
id: String,
name: String,
status: String,
#[serde(default)]
message: Option<String>,
#[serde(default)]
open: Option<String>,
#[serde(default)]
close: Option<String>,
#[serde(default)]
time: Option<String>,
#[serde(default)]
timezone: Vec<RawTimezone>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawTimezone {
#[serde(default)]
dst: Option<String>,
#[serde(default)]
gmtoffset: Option<String>,
#[serde(default)]
short: Option<String>,
#[serde(rename = "$text", default)]
text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MarketTime {
pub id: String,
pub name: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub close: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone_short: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gmt_offset: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dst: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MarketHours {
pub markets: Vec<MarketTime>,
}
fn market_label(id: &str) -> Option<&'static str> {
Some(match id {
"us" => "U.S. markets",
"gb" | "uk" => "U.K. markets",
"ar" => "Argentine markets",
"au" => "Australian markets",
"br" => "Brazilian markets",
"ca" => "Canadian markets",
"cn" => "Chinese markets",
"de" => "German markets",
"dk" => "Danish markets",
"es" => "Spanish markets",
"fi" => "Finnish markets",
"fr" => "French markets",
"gr" => "Greek markets",
"hk" => "Hong Kong markets",
"id" => "Indonesian markets",
"il" => "Israeli markets",
"in" => "Indian markets",
"it" => "Italian markets",
"jp" => "Japanese markets",
"kr" => "South Korean markets",
"mx" => "Mexican markets",
"my" => "Malaysian markets",
"nl" => "Dutch markets",
"no" => "Norwegian markets",
"nz" => "New Zealand markets",
"pt" => "Portuguese markets",
"qa" => "Qatari markets",
"ru" => "Russian markets",
"se" => "Swedish markets",
"sg" => "Singaporean markets",
"th" => "Thai markets",
"tr" => "Turkish markets",
"tw" => "Taiwanese markets",
"vn" => "Vietnamese markets",
_ => return None,
})
}
impl MarketHours {
pub(crate) fn from_response(raw: serde_json::Value) -> Result<Self, String> {
let raw_response: RawHoursResponse = serde_json::from_value(raw)
.map_err(|e| format!("Failed to parse hours response: {}", e))?;
let mut markets = Vec::new();
for market_times in &raw_response.finance.market_times {
for market_time in &market_times.market_time {
let tz = market_time.timezone.first();
let gmt_offset = tz
.and_then(|t| t.gmtoffset.as_ref())
.and_then(|s| s.parse::<i32>().ok());
let dst = tz
.and_then(|t| t.dst.as_ref())
.map(|s| s.eq_ignore_ascii_case("true"));
let (name, message) = match market_label(&market_time.id) {
Some(label) if label != market_time.name => (
label.to_string(),
market_time
.message
.as_ref()
.map(|m| m.replace(&market_time.name, label)),
),
_ => (market_time.name.clone(), market_time.message.clone()),
};
markets.push(MarketTime {
id: market_time.id.clone(),
name,
status: market_time.status.clone(),
message,
open: market_time.open.clone(),
close: market_time.close.clone(),
time: market_time.time.clone(),
timezone: tz.and_then(|t| t.text.clone()),
timezone_short: tz.and_then(|t| t.short.clone()),
gmt_offset,
dst,
});
}
}
Ok(Self { markets })
}
}
#[cfg(feature = "dataframe")]
impl MarketHours {
pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
MarketTime::vec_to_dataframe(&self.markets)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn raw(id: &str, name: &str, message: &str) -> serde_json::Value {
serde_json::json!({
"finance": {
"marketTimes": [{
"marketTime": [{
"id": id,
"name": name,
"status": "closed",
"message": message,
"open": "2026-08-03T00:00:00Z",
"close": "2026-08-03T06:30:00Z",
"time": "2026-08-02T21:14:10Z",
"timezone": [{
"dst": "false",
"gmtoffset": "32400",
"short": "JST",
"$text": "Asia/Tokyo"
}]
}]
}]
}
})
}
#[test]
fn corrects_yahoo_us_label_for_non_us_market() {
let hours = MarketHours::from_response(raw(
"jp",
"U.S. markets",
"U.S. markets open in 2 hours 46 minutes",
))
.unwrap();
let m = &hours.markets[0];
assert_eq!(m.name, "Japanese markets");
assert_eq!(
m.message.as_deref(),
Some("Japanese markets open in 2 hours 46 minutes")
);
assert_eq!(m.open.as_deref(), Some("2026-08-03T00:00:00Z"));
assert_eq!(m.timezone.as_deref(), Some("Asia/Tokyo"));
}
#[test]
fn us_label_and_unknown_ids_pass_through() {
let us =
MarketHours::from_response(raw("us", "U.S. markets", "U.S. markets closed")).unwrap();
assert_eq!(us.markets[0].name, "U.S. markets");
assert_eq!(
us.markets[0].message.as_deref(),
Some("U.S. markets closed")
);
let other =
MarketHours::from_response(raw("zz", "U.S. markets", "U.S. markets closed")).unwrap();
assert_eq!(other.markets[0].name, "U.S. markets");
}
}