Skip to main content

finance_query/models/market/hours/
response.rs

1use serde::{Deserialize, Serialize};
2
3// ============================================================================
4// Raw Yahoo Finance response structures (internal)
5// ============================================================================
6
7/// Raw response from Yahoo Finance markettime endpoint
8#[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// ============================================================================
58// Public API structures
59// ============================================================================
60
61/// Market time information for a specific market
62#[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    /// Market identifier (e.g., "us", "uk", "jp")
68    pub id: String,
69
70    /// Human-readable market name (e.g., "U.S. markets")
71    pub name: String,
72
73    /// Market status (e.g., "open", "closed")
74    pub status: String,
75
76    /// Status message (e.g., "U.S. markets closed")
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub message: Option<String>,
79
80    /// Market open time (ISO 8601 format)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub open: Option<String>,
83
84    /// Market close time (ISO 8601 format)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub close: Option<String>,
87
88    /// Current time (ISO 8601 format)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub time: Option<String>,
91
92    /// Timezone name (e.g., "America/New_York")
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub timezone: Option<String>,
95
96    /// Short timezone name (e.g., "EST")
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub timezone_short: Option<String>,
99
100    /// GMT offset in seconds (e.g., -18000 for EST)
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub gmt_offset: Option<i32>,
103
104    /// Whether daylight saving time is in effect
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub dst: Option<bool>,
107}
108
109/// Flattened response for market hours
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112#[non_exhaustive]
113pub struct MarketHours {
114    /// List of market times
115    pub markets: Vec<MarketTime>,
116}
117
118/// Display label for a Yahoo market id, for the regions the crate supports.
119///
120/// Yahoo's markettime endpoint returns correct per-region session times and
121/// ids, but hardcodes the `"U.S. markets"` label into `name` and `message`
122/// for every region. Unknown ids return `None` and keep Yahoo's label as-is.
123fn 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    /// Create a flattened response from raw Yahoo Finance JSON
165    ///
166    /// Converts the nested Yahoo Finance response structure into a clean,
167    /// user-friendly format. Yahoo labels every region's market
168    /// `"U.S. markets"` in `name`/`message` (the times themselves are
169    /// correct); the label is corrected from the market id here.
170    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                // Extract timezone info from the first timezone entry
179                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    /// Converts the market times to a polars DataFrame.
223    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        // Yahoo returns correct JP session times but labels them "U.S. markets".
259        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        // Times pass through untouched.
272        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        // Unknown id: keep whatever Yahoo said rather than guessing.
287        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}