finance_query/models/market/hours/
response.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize)]
9struct RawHoursResponse {
10 finance: RawFinance,
11}
12
13#[derive(Debug, Clone, Deserialize)]
14#[serde(rename_all = "camelCase")]
15struct RawFinance {
16 #[serde(default)]
17 market_times: Vec<RawMarketTimes>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22struct RawMarketTimes {
23 #[serde(default)]
24 market_time: Vec<RawMarketTime>,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[serde(rename_all = "camelCase")]
29struct RawMarketTime {
30 id: String,
31 name: String,
32 status: String,
33 #[serde(default)]
34 message: Option<String>,
35 #[serde(default)]
36 open: Option<String>,
37 #[serde(default)]
38 close: Option<String>,
39 #[serde(default)]
40 time: Option<String>,
41 #[serde(default)]
42 timezone: Vec<RawTimezone>,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46struct RawTimezone {
47 #[serde(default)]
48 dst: Option<String>,
49 #[serde(default)]
50 gmtoffset: Option<String>,
51 #[serde(default)]
52 short: Option<String>,
53 #[serde(rename = "$text", default)]
54 text: Option<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
64#[serde(rename_all = "camelCase")]
65#[non_exhaustive]
66pub struct MarketTime {
67 pub id: String,
69
70 pub name: String,
72
73 pub status: String,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub message: Option<String>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub open: Option<String>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub close: Option<String>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub time: Option<String>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub timezone: Option<String>,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub timezone_short: Option<String>,
99
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub gmt_offset: Option<i32>,
103
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub dst: Option<bool>,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112#[non_exhaustive]
113pub struct MarketHours {
114 pub markets: Vec<MarketTime>,
116}
117
118fn market_label(id: &str) -> Option<&'static str> {
124 Some(match id {
125 "us" => "U.S. markets",
126 "gb" | "uk" => "U.K. markets",
127 "ar" => "Argentine markets",
128 "au" => "Australian markets",
129 "br" => "Brazilian markets",
130 "ca" => "Canadian markets",
131 "cn" => "Chinese markets",
132 "de" => "German markets",
133 "dk" => "Danish markets",
134 "es" => "Spanish markets",
135 "fi" => "Finnish markets",
136 "fr" => "French markets",
137 "gr" => "Greek markets",
138 "hk" => "Hong Kong markets",
139 "id" => "Indonesian markets",
140 "il" => "Israeli markets",
141 "in" => "Indian markets",
142 "it" => "Italian markets",
143 "jp" => "Japanese markets",
144 "kr" => "South Korean markets",
145 "mx" => "Mexican markets",
146 "my" => "Malaysian markets",
147 "nl" => "Dutch markets",
148 "no" => "Norwegian markets",
149 "nz" => "New Zealand markets",
150 "pt" => "Portuguese markets",
151 "qa" => "Qatari markets",
152 "ru" => "Russian markets",
153 "se" => "Swedish markets",
154 "sg" => "Singaporean markets",
155 "th" => "Thai markets",
156 "tr" => "Turkish markets",
157 "tw" => "Taiwanese markets",
158 "vn" => "Vietnamese markets",
159 _ => return None,
160 })
161}
162
163impl MarketHours {
164 pub(crate) fn from_response(raw: serde_json::Value) -> Result<Self, String> {
171 let raw_response: RawHoursResponse = serde_json::from_value(raw)
172 .map_err(|e| format!("Failed to parse hours response: {}", e))?;
173
174 let mut markets = Vec::new();
175
176 for market_times in &raw_response.finance.market_times {
177 for market_time in &market_times.market_time {
178 let tz = market_time.timezone.first();
180
181 let gmt_offset = tz
182 .and_then(|t| t.gmtoffset.as_ref())
183 .and_then(|s| s.parse::<i32>().ok());
184
185 let dst = tz
186 .and_then(|t| t.dst.as_ref())
187 .map(|s| s.eq_ignore_ascii_case("true"));
188
189 let (name, message) = match market_label(&market_time.id) {
190 Some(label) if label != market_time.name => (
191 label.to_string(),
192 market_time
193 .message
194 .as_ref()
195 .map(|m| m.replace(&market_time.name, label)),
196 ),
197 _ => (market_time.name.clone(), market_time.message.clone()),
198 };
199
200 markets.push(MarketTime {
201 id: market_time.id.clone(),
202 name,
203 status: market_time.status.clone(),
204 message,
205 open: market_time.open.clone(),
206 close: market_time.close.clone(),
207 time: market_time.time.clone(),
208 timezone: tz.and_then(|t| t.text.clone()),
209 timezone_short: tz.and_then(|t| t.short.clone()),
210 gmt_offset,
211 dst,
212 });
213 }
214 }
215
216 Ok(Self { markets })
217 }
218}
219
220#[cfg(feature = "dataframe")]
221impl MarketHours {
222 pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
224 MarketTime::vec_to_dataframe(&self.markets)
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn raw(id: &str, name: &str, message: &str) -> serde_json::Value {
233 serde_json::json!({
234 "finance": {
235 "marketTimes": [{
236 "marketTime": [{
237 "id": id,
238 "name": name,
239 "status": "closed",
240 "message": message,
241 "open": "2026-08-03T00:00:00Z",
242 "close": "2026-08-03T06:30:00Z",
243 "time": "2026-08-02T21:14:10Z",
244 "timezone": [{
245 "dst": "false",
246 "gmtoffset": "32400",
247 "short": "JST",
248 "$text": "Asia/Tokyo"
249 }]
250 }]
251 }]
252 }
253 })
254 }
255
256 #[test]
257 fn corrects_yahoo_us_label_for_non_us_market() {
258 let hours = MarketHours::from_response(raw(
260 "jp",
261 "U.S. markets",
262 "U.S. markets open in 2 hours 46 minutes",
263 ))
264 .unwrap();
265 let m = &hours.markets[0];
266 assert_eq!(m.name, "Japanese markets");
267 assert_eq!(
268 m.message.as_deref(),
269 Some("Japanese markets open in 2 hours 46 minutes")
270 );
271 assert_eq!(m.open.as_deref(), Some("2026-08-03T00:00:00Z"));
273 assert_eq!(m.timezone.as_deref(), Some("Asia/Tokyo"));
274 }
275
276 #[test]
277 fn us_label_and_unknown_ids_pass_through() {
278 let us =
279 MarketHours::from_response(raw("us", "U.S. markets", "U.S. markets closed")).unwrap();
280 assert_eq!(us.markets[0].name, "U.S. markets");
281 assert_eq!(
282 us.markets[0].message.as_deref(),
283 Some("U.S. markets closed")
284 );
285
286 let other =
288 MarketHours::from_response(raw("zz", "U.S. markets", "U.S. markets closed")).unwrap();
289 assert_eq!(other.markets[0].name, "U.S. markets");
290 }
291}