1use std::{borrow::Borrow, sync::Arc};
36
37use bon::Builder;
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40use ustr::Ustr;
41
42use crate::{Error, Result, client::core::DataClient, utils::http_client};
43
44pub static ECONOMIC_CALENDAR_URL: &str = "https://economic-calendar.tradingview.com/events";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49#[repr(i32)]
50pub enum EconomicImportance {
51 Low = -1,
53 Medium = 0,
55 High = 1,
57}
58
59impl std::fmt::Display for EconomicImportance {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 Self::Low => write!(f, "Low"),
63 Self::Medium => write!(f, "Medium"),
64 Self::High => write!(f, "High"),
65 }
66 }
67}
68
69impl From<EconomicImportance> for i32 {
70 #[inline]
71 fn from(importance: EconomicImportance) -> Self {
72 importance as i32
73 }
74}
75
76impl TryFrom<i32> for EconomicImportance {
77 type Error = Error;
78
79 fn try_from(value: i32) -> Result<Self> {
80 match value {
81 -1 => Ok(Self::Low),
82 0 => Ok(Self::Medium),
83 1 => Ok(Self::High),
84 other => Err(Error::Internal(Ustr::from(&format!(
85 "unknown economic importance value: {other} (expected -1, 0, or 1)"
86 )))),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Builder, PartialEq)]
93pub struct EconomicCalendarRequest {
94 pub from: DateTime<Utc>,
96
97 pub to: DateTime<Utc>,
99
100 #[builder(default)]
102 pub countries: Vec<String>,
103
104 pub min_importance: Option<EconomicImportance>,
106}
107
108impl EconomicCalendarRequest {
109 pub fn new(from: DateTime<Utc>, to: DateTime<Utc>) -> Self {
111 Self {
112 from,
113 to,
114 countries: Vec::new(),
115 min_importance: None,
116 }
117 }
118
119 pub fn validate(&self) -> Result<()> {
121 if self.from > self.to {
122 return Err(Error::Internal(Ustr::from(&format!(
123 "invalid date range: 'from' ({}) cannot be greater than 'to' ({})",
124 self.from, self.to
125 ))));
126 }
127 for code in &self.countries {
128 validate_country_code(code)?;
129 }
130 Ok(())
131 }
132
133 pub fn canonical_country_codes(&self) -> Result<Vec<String>> {
135 self.countries
136 .iter()
137 .map(|c| validate_country_code(c))
138 .collect()
139 }
140
141 pub fn matches_filter(&self, event: &EconomicCalendarEvent) -> bool {
143 match self.min_importance {
144 Some(min) => event.importance >= min as i32,
145 None => true,
146 }
147 }
148
149 pub async fn send(&self) -> Result<Vec<EconomicCalendarEvent>> {
151 get_economic_calendar(self).await
152 }
153}
154
155pub fn validate_country_code(code: &str) -> Result<String> {
157 let trimmed = code.trim();
158 if trimmed.len() != 2 || !trimmed.chars().all(|c| c.is_ascii_alphabetic()) {
159 return Err(Error::Internal(Ustr::from(&format!(
160 "invalid iso alpha-2 country code '{code}': expected 2 ascii letters"
161 ))));
162 }
163 Ok(trimmed.to_ascii_uppercase())
164}
165
166pub fn build_query_params(
168 request: &EconomicCalendarRequest,
169) -> Result<Vec<(&'static str, String)>> {
170 request.validate()?;
171 let from_str = request
172 .from
173 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
174 let to_str = request
175 .to
176 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
177
178 let mut params = vec![("from", from_str), ("to", to_str)];
179
180 let canonical = request.canonical_country_codes()?;
181 if !canonical.is_empty() {
182 params.push(("countries", canonical.join(",")));
183 }
184
185 Ok(params)
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct EconomicCalendarEvent {
191 pub id: String,
193
194 pub title: String,
196
197 pub country: String,
199
200 pub indicator: String,
202
203 #[serde(default)]
205 pub ticker: Option<String>,
206
207 #[serde(default)]
209 pub comment: Option<String>,
210
211 #[serde(default)]
213 pub category: Option<String>,
214
215 #[serde(default)]
217 pub period: String,
218
219 #[serde(
221 rename = "referenceDate",
222 default,
223 deserialize_with = "deserialize_optional_datetime"
224 )]
225 pub reference_date: Option<DateTime<Utc>>,
226
227 #[serde(default)]
229 pub source: String,
230
231 #[serde(rename = "source_url", default)]
233 pub source_url: String,
234
235 #[serde(default)]
237 pub actual: Option<f64>,
238
239 #[serde(default)]
241 pub previous: Option<f64>,
242
243 #[serde(default)]
245 pub forecast: Option<f64>,
246
247 #[serde(rename = "actualRaw", default)]
249 pub actual_raw: Option<f64>,
250
251 #[serde(rename = "previousRaw", default)]
253 pub previous_raw: Option<f64>,
254
255 #[serde(rename = "forecastRaw", default)]
257 pub forecast_raw: Option<f64>,
258
259 #[serde(default)]
261 pub currency: String,
262
263 #[serde(default)]
265 pub unit: Option<String>,
266
267 #[serde(default)]
269 pub scale: Option<String>,
270
271 pub importance: i32,
273
274 pub date: DateTime<Utc>,
276}
277
278impl EconomicCalendarEvent {
279 pub fn importance_level(&self) -> Option<EconomicImportance> {
281 EconomicImportance::try_from(self.importance).ok()
282 }
283}
284
285#[derive(Debug, Deserialize)]
286struct EconomicCalendarResponse {
287 status: String,
288 #[serde(default)]
289 result: Option<Vec<EconomicCalendarEvent>>,
290 #[serde(default)]
291 errmsg: Option<String>,
292 #[serde(default)]
293 message: Option<String>,
294}
295
296fn deserialize_optional_datetime<'de, D>(
297 deserializer: D,
298) -> std::result::Result<Option<DateTime<Utc>>, D::Error>
299where
300 D: serde::Deserializer<'de>,
301{
302 match Option::<String>::deserialize(deserializer)? {
303 Some(s) if !s.trim().is_empty() => DateTime::parse_from_rfc3339(&s)
304 .map(|dt| Some(dt.with_timezone(&Utc)))
305 .map_err(serde::de::Error::custom),
306 _ => Ok(None),
307 }
308}
309
310pub async fn get_economic_calendar(
312 request: impl Borrow<EconomicCalendarRequest>,
313) -> Result<Vec<EconomicCalendarEvent>> {
314 let client = http_client();
315 get_economic_calendar_with_client(&client, request.borrow()).await
316}
317
318pub async fn get_economic_calendar_with_client(
320 client: &reqwest::Client,
321 request: &EconomicCalendarRequest,
322) -> Result<Vec<EconomicCalendarEvent>> {
323 let query_params = build_query_params(request)?;
324
325 let response = client
326 .get(ECONOMIC_CALENDAR_URL)
327 .query(&query_params)
328 .send()
329 .await
330 .map_err(|e| {
331 Error::Request(Ustr::from(&format!(
332 "economic calendar request failed: {e}"
333 )))
334 })?;
335
336 let status = response.status();
337 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
338 return Err(Error::RateLimited(Ustr::from(
339 "economic calendar rate limited (HTTP 429)",
340 )));
341 }
342 if !status.is_success() {
343 let body = response.text().await.map_err(|e| {
344 Error::Request(Ustr::from(&format!("failed to read response body: {e}")))
345 })?;
346 return Err(Error::Request(Ustr::from(&format!(
347 "economic calendar request failed with status {status}: {body}"
348 ))));
349 }
350
351 let parsed = response
352 .json::<EconomicCalendarResponse>()
353 .await
354 .map_err(|e| {
355 Error::JsonParse(Ustr::from(&format!(
356 "failed to parse economic calendar response: {e}"
357 )))
358 })?;
359
360 if parsed.status != "ok" {
361 let msg = parsed
362 .errmsg
363 .as_deref()
364 .or(parsed.message.as_deref())
365 .unwrap_or("unknown error");
366 return Err(Error::Request(Ustr::from(&format!(
367 "tradingview economic calendar error (status '{}'): {msg}",
368 parsed.status
369 ))));
370 }
371
372 let events = parsed.result.unwrap_or_default();
373 Ok(events
374 .into_iter()
375 .filter(|ev| request.matches_filter(ev))
376 .collect())
377}
378
379#[derive(Debug, Default, Clone)]
381pub struct EconomicCalendarClient {
382 client: reqwest::Client,
383}
384
385impl EconomicCalendarClient {
386 pub fn new() -> Self {
388 Self {
389 client: http_client(),
390 }
391 }
392
393 pub fn with_client(client: reqwest::Client) -> Self {
395 Self { client }
396 }
397
398 pub async fn get_events(
400 &self,
401 request: impl Borrow<EconomicCalendarRequest>,
402 ) -> Result<Vec<EconomicCalendarEvent>> {
403 get_economic_calendar_with_client(&self.client, request.borrow()).await
404 }
405}
406
407impl DataClient for EconomicCalendarClient {
408 fn new(_auth_token: Option<&str>) -> Arc<Self> {
409 Arc::new(Self::new())
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use chrono::TimeZone;
416
417 use super::*;
418
419 #[test]
420 fn test_validation() {
421 assert_eq!(validate_country_code("US").unwrap(), "US");
422 assert_eq!(validate_country_code("de").unwrap(), "DE");
423 assert!(validate_country_code("USA").is_err());
424 assert!(validate_country_code("12").is_err());
425
426 let t1 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
427 let t2 = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
428
429 assert!(EconomicCalendarRequest::new(t1, t2).validate().is_ok());
430 assert!(EconomicCalendarRequest::new(t2, t1).validate().is_err());
431 }
432
433 #[test]
434 fn test_query_params_and_encoding() {
435 let from = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
436 let to = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
437
438 let req = EconomicCalendarRequest::builder()
439 .from(from)
440 .to(to)
441 .countries(vec!["us".to_string(), "DE".to_string()])
442 .build();
443
444 let params = build_query_params(&req).unwrap();
445 assert_eq!(
446 params,
447 vec![
448 ("from", "2025-01-01T00:00:00.000Z".to_string()),
449 ("to", "2025-01-02T00:00:00.000Z".to_string()),
450 ("countries", "US,DE".to_string()),
451 ]
452 );
453
454 let url = reqwest::Client::new()
455 .get(ECONOMIC_CALENDAR_URL)
456 .query(¶ms)
457 .build()
458 .unwrap()
459 .url()
460 .to_string();
461
462 assert!(url.contains("from=2025-01-01T00%3A00%3A00.000Z"));
463 assert!(url.contains("countries=US%2CDE"));
464 }
465
466 #[test]
467 fn test_deserialize_observed_json() {
468 let json_data = r#"{
469 "status": "ok",
470 "result": [
471 {
472 "id": "371705",
473 "title": "Inflation Rate YoY Prel",
474 "country": "DE",
475 "indicator": "Inflation Rate",
476 "ticker": "ECONOMICS:DEIRYY",
477 "comment": "Commentary",
478 "category": "prce",
479 "period": "Dec",
480 "referenceDate": "2024-12-31T00:00:00Z",
481 "source": "Federal Statistical Office",
482 "source_url": "https://www.destatis.de",
483 "actual": 2.6,
484 "previous": 2.2,
485 "forecast": 2.4,
486 "actualRaw": 2.6,
487 "previousRaw": 2.2,
488 "forecastRaw": 2.4,
489 "currency": "EUR",
490 "unit": "%",
491 "importance": 1,
492 "date": "2025-01-06T13:00:00.000Z"
493 },
494 {
495 "id": "367073",
496 "title": "New Year’s Day",
497 "country": "US",
498 "indicator": "Holidays",
499 "period": "",
500 "referenceDate": null,
501 "source": "",
502 "source_url": "",
503 "actual": null,
504 "previous": null,
505 "forecast": null,
506 "actualRaw": null,
507 "previousRaw": null,
508 "forecastRaw": null,
509 "currency": "USD",
510 "importance": -1,
511 "date": "2025-01-01T00:00:00.000Z"
512 }
513 ]
514 }"#;
515
516 let resp: EconomicCalendarResponse = serde_json::from_str(json_data).unwrap();
517 let events = resp.result.unwrap();
518 assert_eq!(events.len(), 2);
519
520 assert_eq!(events[0].id, "371705");
521 assert_eq!(events[0].actual, Some(2.6));
522 assert_eq!(events[0].importance_level(), Some(EconomicImportance::High));
523 assert_eq!(
524 events[0].reference_date,
525 Some(Utc.with_ymd_and_hms(2024, 12, 31, 0, 0, 0).unwrap())
526 );
527
528 assert_eq!(events[1].id, "367073");
529 assert_eq!(events[1].actual, None);
530 assert_eq!(events[1].reference_date, None);
531 assert_eq!(events[1].importance_level(), Some(EconomicImportance::Low));
532 }
533
534 #[test]
535 fn test_error_response_rejection() {
536 let json_err = r#"{"status": "bad_request", "errmsg": "parse error"}"#;
537 let resp: EconomicCalendarResponse = serde_json::from_str(json_err).unwrap();
538 assert_eq!(resp.status, "bad_request");
539 assert_eq!(resp.errmsg.as_deref(), Some("parse error"));
540 }
541
542 #[test]
543 fn test_importance_client_side_filtering() {
544 let make_event = |importance: i32| EconomicCalendarEvent {
545 id: "1".to_string(),
546 title: "T".to_string(),
547 country: "US".to_string(),
548 indicator: "I".to_string(),
549 ticker: None,
550 comment: None,
551 category: None,
552 period: "".to_string(),
553 reference_date: None,
554 source: "".to_string(),
555 source_url: "".to_string(),
556 actual: None,
557 previous: None,
558 forecast: None,
559 actual_raw: None,
560 previous_raw: None,
561 forecast_raw: None,
562 currency: "USD".to_string(),
563 unit: None,
564 scale: None,
565 importance,
566 date: Utc::now(),
567 };
568
569 let now = Utc::now();
570 let req = EconomicCalendarRequest::builder()
571 .from(now)
572 .to(now)
573 .min_importance(EconomicImportance::Medium)
574 .build();
575
576 assert!(!req.matches_filter(&make_event(-1)));
577 assert!(req.matches_filter(&make_event(0)));
578 assert!(req.matches_filter(&make_event(1)));
579 }
580
581 #[tokio::test]
582 async fn test_request_validation_fails_locally() {
583 let from = Utc.with_ymd_and_hms(2025, 1, 5, 0, 0, 0).unwrap();
584 let to = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
585
586 let req_invalid_dates = EconomicCalendarRequest::new(from, to);
587 assert!(get_economic_calendar(&req_invalid_dates).await.is_err());
588
589 let req_invalid_country = EconomicCalendarRequest::builder()
590 .from(to)
591 .to(from)
592 .countries(vec!["INVALID".to_string()])
593 .build();
594 assert!(get_economic_calendar(&req_invalid_country).await.is_err());
595 }
596
597 #[tokio::test]
598 #[ignore = "requires network access to economic-calendar.tradingview.com"]
599 async fn test_live_fetch() -> Result<()> {
600 let from = Utc::now() - chrono::Duration::days(2);
601 let to = Utc::now() + chrono::Duration::days(5);
602 let req = EconomicCalendarRequest::builder()
603 .from(from)
604 .to(to)
605 .countries(vec!["US".to_string(), "DE".to_string()])
606 .min_importance(EconomicImportance::Medium)
607 .build();
608
609 let events = get_economic_calendar(&req).await?;
610 assert!(!events.is_empty());
611 for ev in &events {
612 assert!(ev.importance >= 0);
613 }
614 Ok(())
615 }
616}