Skip to main content

tradingview/client/
fin_calendar.rs

1//! Economic calendar REST API client.
2//!
3//! TradingView provides an economic calendar endpoint returning scheduled
4//! macroeconomic events, speeches, auctions, and indicators across countries.
5//!
6//! # Endpoint
7//!
8//! `GET https://economic-calendar.tradingview.com/events`
9//!
10//! # Example
11//!
12//! ```no_run
13//! use chrono::{Duration, Utc};
14//! use tradingview::client::fin_calendar::{
15//!     EconomicCalendarRequest, EconomicImportance, get_economic_calendar,
16//! };
17//!
18//! # async fn run() -> tradingview::Result<()> {
19//! let now = Utc::now();
20//! let request = EconomicCalendarRequest::builder()
21//!     .from(now)
22//!     .to(now + Duration::days(7))
23//!     .countries(vec!["US".to_string(), "DE".to_string()])
24//!     .min_importance(EconomicImportance::Medium)
25//!     .build();
26//!
27//! let events = get_economic_calendar(&request).await?;
28//! for event in events {
29//!     println!("{}: {} ({:?})", event.date, event.title, event.importance_level());
30//! }
31//! # Ok(())
32//! # }
33//! ```
34
35use 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
44/// Default endpoint URL for TradingView's economic calendar events.
45pub static ECONOMIC_CALENDAR_URL: &str = "https://economic-calendar.tradingview.com/events";
46
47/// Macroeconomic event importance level.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49#[repr(i32)]
50pub enum EconomicImportance {
51    /// Low / minor importance (-1).
52    Low = -1,
53    /// Medium / moderate importance (0).
54    Medium = 0,
55    /// High / major importance (1).
56    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/// Request parameters for querying TradingView's economic calendar.
92#[derive(Debug, Clone, Builder, PartialEq)]
93pub struct EconomicCalendarRequest {
94    /// Start of date range (inclusive, UTC).
95    pub from: DateTime<Utc>,
96
97    /// End of date range (inclusive, UTC).
98    pub to: DateTime<Utc>,
99
100    /// Optional list of ISO 3166-1 alpha-2 country codes (e.g. `["US", "DE"]`).
101    #[builder(default)]
102    pub countries: Vec<String>,
103
104    /// Optional minimum importance filter applied client-side.
105    pub min_importance: Option<EconomicImportance>,
106}
107
108impl EconomicCalendarRequest {
109    /// Creates a new request for the specified UTC date range.
110    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    /// Validates request parameters without network interaction.
120    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    /// Returns canonical uppercase ISO 3166-1 alpha-2 country codes.
134    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    /// Checks whether an event passes the configured importance filter.
142    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    /// Sends this request using the shared [`http_client`].
150    pub async fn send(&self) -> Result<Vec<EconomicCalendarEvent>> {
151        get_economic_calendar(self).await
152    }
153}
154
155/// Validates and canonicalizes an ISO 3166-1 alpha-2 country code.
156pub 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
166/// Builds the query parameter list for the economic calendar endpoint.
167pub 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/// A single macroeconomic calendar event from TradingView.
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct EconomicCalendarEvent {
191    /// Unique event identifier (e.g. `"367073"`).
192    pub id: String,
193
194    /// Human-readable title of the event.
195    pub title: String,
196
197    /// ISO 3166-1 alpha-2 country code.
198    pub country: String,
199
200    /// Indicator name.
201    pub indicator: String,
202
203    /// TradingView ticker symbol if available.
204    #[serde(default)]
205    pub ticker: Option<String>,
206
207    /// Descriptive commentary or context about the metric.
208    #[serde(default)]
209    pub comment: Option<String>,
210
211    /// Category code (e.g. `"lbr"`, `"mny"`, `"hse"`).
212    #[serde(default)]
213    pub category: Option<String>,
214
215    /// Reporting period string (e.g. `"Dec"`).
216    #[serde(default)]
217    pub period: String,
218
219    /// Reference date for the data release, if applicable.
220    #[serde(
221        rename = "referenceDate",
222        default,
223        deserialize_with = "deserialize_optional_datetime"
224    )]
225    pub reference_date: Option<DateTime<Utc>>,
226
227    /// Data source organization.
228    #[serde(default)]
229    pub source: String,
230
231    /// URL to the official source website.
232    #[serde(rename = "source_url", default)]
233    pub source_url: String,
234
235    /// Actual released value.
236    #[serde(default)]
237    pub actual: Option<f64>,
238
239    /// Previous period's value.
240    #[serde(default)]
241    pub previous: Option<f64>,
242
243    /// Market consensus forecast value.
244    #[serde(default)]
245    pub forecast: Option<f64>,
246
247    /// Raw unscaled actual value.
248    #[serde(rename = "actualRaw", default)]
249    pub actual_raw: Option<f64>,
250
251    /// Raw unscaled previous value.
252    #[serde(rename = "previousRaw", default)]
253    pub previous_raw: Option<f64>,
254
255    /// Raw unscaled forecast value.
256    #[serde(rename = "forecastRaw", default)]
257    pub forecast_raw: Option<f64>,
258
259    /// Currency associated with the event.
260    #[serde(default)]
261    pub currency: String,
262
263    /// Display unit of measurement.
264    #[serde(default)]
265    pub unit: Option<String>,
266
267    /// Scale multiplier (e.g. `"K"`, `"M"`, `"B"`, `"T"`).
268    #[serde(default)]
269    pub scale: Option<String>,
270
271    /// Importance level: -1 (Low), 0 (Medium), 1 (High).
272    pub importance: i32,
273
274    /// Scheduled release timestamp in UTC.
275    pub date: DateTime<Utc>,
276}
277
278impl EconomicCalendarEvent {
279    /// Returns the importance as a typed [`EconomicImportance`] if recognized.
280    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
310/// Fetches economic calendar events from TradingView using the shared [`http_client`].
311pub 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
318/// Fetches economic calendar events using the specified [`reqwest::Client`].
319pub 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/// Client for TradingView's Economic Calendar REST API.
380#[derive(Debug, Default, Clone)]
381pub struct EconomicCalendarClient {
382    client: reqwest::Client,
383}
384
385impl EconomicCalendarClient {
386    /// Creates a new client reusing the crate's shared [`http_client`].
387    pub fn new() -> Self {
388        Self {
389            client: http_client(),
390        }
391    }
392
393    /// Creates a new client with a custom [`reqwest::Client`].
394    pub fn with_client(client: reqwest::Client) -> Self {
395        Self { client }
396    }
397
398    /// Fetches economic calendar events for the given request.
399    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(&params)
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}