Skip to main content

tvdata_rs/client/
mod.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3use std::time::Duration;
4
5use bon::{Builder, bon};
6use reqwest::header::{COOKIE, HeaderMap, HeaderValue, ORIGIN, REFERER};
7use reqwest_middleware::{
8    ClientBuilder as MiddlewareClientBuilder, ClientWithMiddleware, RequestBuilder,
9};
10use reqwest_retry::{Jitter, RetryTransientMiddleware, policies::ExponentialBackoff};
11use serde::de::DeserializeOwned;
12use tokio::sync::RwLock;
13use url::Url;
14
15use crate::calendar::{
16    CalendarWindowRequest, DividendCalendarEntry, DividendCalendarRequest, EarningsCalendarEntry,
17    IpoCalendarEntry,
18};
19use crate::economics::{
20    EconomicCalendarRequest, EconomicCalendarResponse, RawEconomicCalendarResponse,
21    sanitize_calendar,
22};
23use crate::error::{Error, Result};
24use crate::history::{HistoryRequest, HistorySeries, fetch_history};
25use crate::scanner::{
26    Market, PartiallySupportedColumn, RawScanResponse, ScanQuery, ScanResponse,
27    ScanValidationReport, ScannerMetainfo, ScreenerKind, embedded_registry,
28};
29use crate::search::{
30    RawSearchResponse, SearchHit, SearchRequest, SearchResponse, sanitize_response,
31};
32
33const DEFAULT_USER_AGENT: &str =
34    "tvdata-rs/0.1 (+https://github.com/deepentropy/tvscreener reference)";
35const DEFAULT_AUTH_TOKEN: &str = "unauthorized_user_token";
36
37fn default_scanner_base_url() -> Url {
38    Url::parse("https://scanner.tradingview.com").expect("default scanner endpoint must be valid")
39}
40
41fn default_symbol_search_base_url() -> Url {
42    Url::parse("https://symbol-search.tradingview.com/symbol_search/v3/")
43        .expect("default symbol search endpoint must be valid")
44}
45
46fn default_calendar_base_url() -> Url {
47    Url::parse("https://chartevents-reuters.tradingview.com/events")
48        .expect("default calendar endpoint must be valid")
49}
50
51fn default_websocket_url() -> Url {
52    Url::parse("wss://data.tradingview.com/socket.io/websocket")
53        .expect("default websocket endpoint must be valid")
54}
55
56fn default_site_origin() -> Url {
57    Url::parse("https://www.tradingview.com").expect("default site origin must be valid")
58}
59
60fn default_data_origin() -> Url {
61    Url::parse("https://data.tradingview.com").expect("default data origin must be valid")
62}
63
64fn default_timeout() -> Duration {
65    Duration::from_secs(30)
66}
67
68fn default_user_agent() -> String {
69    DEFAULT_USER_AGENT.to_owned()
70}
71
72fn default_auth_token() -> String {
73    DEFAULT_AUTH_TOKEN.to_owned()
74}
75
76fn cookie_header_value(session_id: &str) -> Result<HeaderValue> {
77    HeaderValue::from_str(&format!("sessionid={session_id}"))
78        .map_err(|_| Error::Protocol("invalid session id configured for cookie header"))
79}
80
81fn default_min_retry_interval() -> Duration {
82    Duration::from_millis(250)
83}
84
85fn default_max_retry_interval() -> Duration {
86    Duration::from_secs(2)
87}
88
89fn parse_url(value: impl AsRef<str>) -> Result<Url> {
90    Url::parse(value.as_ref()).map_err(Into::into)
91}
92
93fn referer(origin: &Url) -> String {
94    format!("{}/", origin.as_str().trim_end_matches('/'))
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum RetryJitter {
99    None,
100    Full,
101    #[default]
102    Bounded,
103}
104
105impl From<RetryJitter> for Jitter {
106    fn from(value: RetryJitter) -> Self {
107        match value {
108            RetryJitter::None => Self::None,
109            RetryJitter::Full => Self::Full,
110            RetryJitter::Bounded => Self::Bounded,
111        }
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Builder)]
116pub struct RetryConfig {
117    #[builder(default = 2)]
118    pub max_retries: u32,
119    #[builder(default = default_min_retry_interval())]
120    pub min_retry_interval: Duration,
121    #[builder(default = default_max_retry_interval())]
122    pub max_retry_interval: Duration,
123    #[builder(default)]
124    pub jitter: RetryJitter,
125}
126
127impl Default for RetryConfig {
128    fn default() -> Self {
129        Self::builder().build()
130    }
131}
132
133impl RetryConfig {
134    pub fn disabled() -> Self {
135        Self {
136            max_retries: 0,
137            ..Self::default()
138        }
139    }
140
141    fn validate(&self) -> Result<()> {
142        if self.min_retry_interval > self.max_retry_interval {
143            return Err(Error::InvalidRetryBounds {
144                min: self.min_retry_interval,
145                max: self.max_retry_interval,
146            });
147        }
148
149        Ok(())
150    }
151
152    fn to_policy(&self) -> ExponentialBackoff {
153        ExponentialBackoff::builder()
154            .retry_bounds(self.min_retry_interval, self.max_retry_interval)
155            .jitter(self.jitter.into())
156            .build_with_max_retries(self.max_retries)
157    }
158}
159
160/// Typed endpoint configuration for the TradingView surfaces used by the client.
161#[derive(Debug, Clone, PartialEq, Eq, Builder)]
162pub struct Endpoints {
163    #[builder(default = default_scanner_base_url())]
164    scanner_base_url: Url,
165    #[builder(default = default_symbol_search_base_url())]
166    symbol_search_base_url: Url,
167    #[builder(default = default_calendar_base_url())]
168    calendar_base_url: Url,
169    #[builder(default = default_websocket_url())]
170    websocket_url: Url,
171    #[builder(default = default_site_origin())]
172    site_origin: Url,
173    #[builder(default = default_data_origin())]
174    data_origin: Url,
175}
176
177impl Default for Endpoints {
178    fn default() -> Self {
179        Self::builder().build()
180    }
181}
182
183impl Endpoints {
184    pub fn scanner_base_url(&self) -> &Url {
185        &self.scanner_base_url
186    }
187
188    pub fn symbol_search_base_url(&self) -> &Url {
189        &self.symbol_search_base_url
190    }
191
192    pub fn calendar_base_url(&self) -> &Url {
193        &self.calendar_base_url
194    }
195
196    pub fn websocket_url(&self) -> &Url {
197        &self.websocket_url
198    }
199
200    pub fn site_origin(&self) -> &Url {
201        &self.site_origin
202    }
203
204    pub fn data_origin(&self) -> &Url {
205        &self.data_origin
206    }
207
208    pub fn with_scanner_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
209        self.scanner_base_url = parse_url(url)?;
210        Ok(self)
211    }
212
213    pub fn with_symbol_search_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
214        self.symbol_search_base_url = parse_url(url)?;
215        Ok(self)
216    }
217
218    pub fn with_calendar_base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
219        self.calendar_base_url = parse_url(url)?;
220        Ok(self)
221    }
222
223    pub fn with_websocket_url(mut self, url: impl AsRef<str>) -> Result<Self> {
224        self.websocket_url = parse_url(url)?;
225        Ok(self)
226    }
227
228    pub fn with_site_origin(mut self, url: impl AsRef<str>) -> Result<Self> {
229        self.site_origin = parse_url(url)?;
230        Ok(self)
231    }
232
233    pub fn with_data_origin(mut self, url: impl AsRef<str>) -> Result<Self> {
234        self.data_origin = parse_url(url)?;
235        Ok(self)
236    }
237
238    pub fn scanner_url(&self, route: &str) -> Result<Url> {
239        self.scanner_base_url
240            .join(route.trim_start_matches('/'))
241            .map_err(Into::into)
242    }
243
244    pub fn scanner_metainfo_url(&self, market: &Market) -> Result<Url> {
245        self.scanner_url(&format!("{}/metainfo", market.as_str()))
246    }
247}
248
249/// High-level entry point for TradingView screener, search, quote, and history data.
250///
251/// Most consumers should start with [`TradingViewClient::builder`] and then use one of the
252/// product-oriented facades such as [`TradingViewClient::equity`],
253/// [`TradingViewClient::crypto`], or [`TradingViewClient::forex`].
254///
255/// # Examples
256///
257/// ```no_run
258/// use tvdata_rs::{Result, TradingViewClient};
259///
260/// #[tokio::main]
261/// async fn main() -> Result<()> {
262///     let client = TradingViewClient::builder().build()?;
263///
264///     let quote = client.equity().quote("NASDAQ:AAPL").await?;
265///     println!("{:?}", quote.close);
266///
267///     Ok(())
268/// }
269/// ```
270#[derive(Debug, Clone)]
271pub struct TradingViewClient {
272    http: ClientWithMiddleware,
273    endpoints: Endpoints,
274    user_agent: String,
275    auth_token: String,
276    session_id: Option<String>,
277    metainfo_cache: Arc<RwLock<HashMap<String, ScannerMetainfo>>>,
278}
279
280#[bon]
281impl TradingViewClient {
282    /// Builds a [`TradingViewClient`] with validated endpoint configuration and retry settings.
283    #[builder]
284    pub fn new(
285        #[builder(default = Endpoints::default())] endpoints: Endpoints,
286        #[builder(default = default_timeout())] timeout: Duration,
287        #[builder(default = RetryConfig::default())] retry: RetryConfig,
288        #[builder(default = default_user_agent(), into)] user_agent: String,
289        #[builder(default = default_auth_token(), into)] auth_token: String,
290        #[builder(into)] session_id: Option<String>,
291    ) -> Result<Self> {
292        retry.validate()?;
293
294        let mut headers = HeaderMap::new();
295        headers.insert(
296            ORIGIN,
297            HeaderValue::from_str(endpoints.site_origin.as_str()).map_err(|_| {
298                Error::Protocol("invalid site origin configured for reqwest client")
299            })?,
300        );
301        headers.insert(
302            REFERER,
303            HeaderValue::from_str(&referer(&endpoints.site_origin))
304                .map_err(|_| Error::Protocol("invalid referer configured for reqwest client"))?,
305        );
306        if let Some(session_id) = session_id.as_deref() {
307            headers.insert(COOKIE, cookie_header_value(session_id)?);
308        }
309
310        let base_http = reqwest::Client::builder()
311            .default_headers(headers)
312            .timeout(timeout)
313            .user_agent(&user_agent)
314            .build()
315            .map_err(Error::from)?;
316
317        let http = if retry.max_retries == 0 {
318            ClientWithMiddleware::from(base_http)
319        } else {
320            MiddlewareClientBuilder::new(base_http)
321                .with(RetryTransientMiddleware::new_with_policy(retry.to_policy()))
322                .build()
323        };
324
325        Ok(Self {
326            http,
327            endpoints,
328            user_agent,
329            auth_token,
330            session_id,
331            metainfo_cache: Arc::new(RwLock::new(HashMap::new())),
332        })
333    }
334
335    pub fn endpoints(&self) -> &Endpoints {
336        &self.endpoints
337    }
338
339    pub(crate) fn user_agent(&self) -> &str {
340        &self.user_agent
341    }
342
343    pub(crate) fn auth_token(&self) -> &str {
344        &self.auth_token
345    }
346
347    pub(crate) fn session_id(&self) -> Option<&str> {
348        self.session_id.as_deref()
349    }
350
351    /// Executes a low-level TradingView screener query.
352    ///
353    /// This is the most flexible API in the crate and is useful when you need fields or filters
354    /// that are not covered by the higher-level market facades.
355    ///
356    /// # Examples
357    ///
358    /// ```no_run
359    /// use tvdata_rs::scanner::fields::{core, price};
360    /// use tvdata_rs::scanner::ScanQuery;
361    /// use tvdata_rs::{Result, TradingViewClient};
362    ///
363    /// #[tokio::main]
364    /// async fn main() -> Result<()> {
365    ///     let client = TradingViewClient::builder().build()?;
366    ///     let query = ScanQuery::new()
367    ///         .market("america")
368    ///         .select([core::NAME, price::CLOSE])
369    ///         .page(0, 10)?;
370    ///
371    ///     let response = client.scan(&query).await?;
372    ///     println!("rows: {}", response.rows.len());
373    ///
374    ///     Ok(())
375    /// }
376    /// ```
377    pub async fn scan(&self, query: &ScanQuery) -> Result<ScanResponse> {
378        let raw: RawScanResponse = self
379            .execute_json(
380                self.http
381                    .post(self.endpoints.scanner_url(&query.route_segment())?)
382                    .json(query),
383            )
384            .await?;
385
386        raw.into_response()
387    }
388
389    /// Validates a scan query against live TradingView metainfo before execution.
390    ///
391    /// Validation currently requires the query to specify one or more markets so the
392    /// client can resolve the corresponding `/{market}/metainfo` endpoints.
393    ///
394    /// # Examples
395    ///
396    /// ```no_run
397    /// use tvdata_rs::scanner::fields::{core, price};
398    /// use tvdata_rs::scanner::ScanQuery;
399    /// use tvdata_rs::{Result, TradingViewClient};
400    ///
401    /// #[tokio::main]
402    /// async fn main() -> Result<()> {
403    ///     let client = TradingViewClient::builder().build()?;
404    ///     let query = ScanQuery::new()
405    ///         .market("america")
406    ///         .select([core::NAME, price::CLOSE]);
407    ///
408    ///     let report = client.validate_scan_query(&query).await?;
409    ///     assert!(report.is_strictly_supported());
410    ///     Ok(())
411    /// }
412    /// ```
413    pub async fn validate_scan_query(&self, query: &ScanQuery) -> Result<ScanValidationReport> {
414        let route_segment = query.route_segment();
415        let markets = validation_markets(query)?;
416        let mut market_metainfo = Vec::with_capacity(markets.len());
417
418        for market in &markets {
419            market_metainfo.push((market.clone(), self.cached_metainfo(market).await?));
420        }
421
422        let mut supported_columns = Vec::new();
423        let mut partially_supported_columns = Vec::new();
424        let mut unsupported_columns = Vec::new();
425        let mut seen = HashSet::new();
426
427        for column in &query.columns {
428            if !seen.insert(column.as_str().to_owned()) {
429                continue;
430            }
431
432            let mut supported_markets = Vec::new();
433            let mut unsupported_markets = Vec::new();
434
435            for (market, metainfo) in &market_metainfo {
436                if supports_column_for_market(market, metainfo, column.as_str()) {
437                    supported_markets.push(market.clone());
438                } else {
439                    unsupported_markets.push(market.clone());
440                }
441            }
442
443            match (supported_markets.is_empty(), unsupported_markets.is_empty()) {
444                (true, false) => unsupported_columns.push(column.clone()),
445                (false, true) => supported_columns.push(column.clone()),
446                (false, false) => partially_supported_columns.push(PartiallySupportedColumn {
447                    column: column.clone(),
448                    supported_markets,
449                    unsupported_markets,
450                }),
451                (true, true) => {}
452            }
453        }
454
455        Ok(ScanValidationReport {
456            route_segment,
457            requested_markets: markets,
458            supported_columns,
459            partially_supported_columns,
460            unsupported_columns,
461        })
462    }
463
464    /// Executes a scan only after validating all requested fields against live TradingView
465    /// metainfo for the selected markets.
466    ///
467    /// # Examples
468    ///
469    /// ```no_run
470    /// use tvdata_rs::scanner::fields::{core, price};
471    /// use tvdata_rs::scanner::ScanQuery;
472    /// use tvdata_rs::{Result, TradingViewClient};
473    ///
474    /// #[tokio::main]
475    /// async fn main() -> Result<()> {
476    ///     let client = TradingViewClient::builder().build()?;
477    ///     let query = ScanQuery::new()
478    ///         .market("america")
479    ///         .select([core::NAME, price::CLOSE]);
480    ///
481    ///     let response = client.scan_validated(&query).await?;
482    ///     println!("rows: {}", response.rows.len());
483    ///     Ok(())
484    /// }
485    /// ```
486    pub async fn scan_validated(&self, query: &ScanQuery) -> Result<ScanResponse> {
487        let report = self.validate_scan_query(query).await?;
488        if !report.is_strictly_supported() {
489            let fields = report
490                .strict_violation_column_names()
491                .into_iter()
492                .map(str::to_owned)
493                .collect();
494            return Err(Error::UnsupportedScanFields {
495                route: report.route_segment,
496                fields,
497            });
498        }
499
500        self.scan(query).await
501    }
502
503    /// Filters a scan query down to columns that are fully supported across the selected
504    /// markets according to live TradingView metainfo plus the embedded registry fallback.
505    ///
506    /// Partially supported columns are removed from the filtered query to keep the result
507    /// safe across all requested markets.
508    ///
509    /// # Examples
510    ///
511    /// ```no_run
512    /// use tvdata_rs::scanner::fields::{fundamentals, price};
513    /// use tvdata_rs::scanner::ScanQuery;
514    /// use tvdata_rs::{Result, TradingViewClient};
515    ///
516    /// #[tokio::main]
517    /// async fn main() -> Result<()> {
518    ///     let client = TradingViewClient::builder().build()?;
519    ///     let query = ScanQuery::new()
520    ///         .markets(["america", "crypto"])
521    ///         .select([price::CLOSE, fundamentals::MARKET_CAP_BASIC]);
522    ///
523    ///     let (filtered, report) = client.filter_scan_query(&query).await?;
524    ///     println!("filtered columns: {:?}", report.filtered_column_names());
525    ///     assert!(!filtered.columns.is_empty());
526    ///     Ok(())
527    /// }
528    /// ```
529    pub async fn filter_scan_query(
530        &self,
531        query: &ScanQuery,
532    ) -> Result<(ScanQuery, ScanValidationReport)> {
533        let report = self.validate_scan_query(query).await?;
534        let filtered = report.filtered_query(query);
535
536        if filtered.columns.is_empty() {
537            let fields = report
538                .strict_violation_column_names()
539                .into_iter()
540                .map(str::to_owned)
541                .collect();
542            return Err(Error::UnsupportedScanFields {
543                route: report.route_segment,
544                fields,
545            });
546        }
547
548        Ok((filtered, report))
549    }
550
551    /// Executes a scan after dropping columns that are not fully supported across
552    /// all selected markets.
553    ///
554    /// # Examples
555    ///
556    /// ```no_run
557    /// use tvdata_rs::scanner::fields::{fundamentals, price};
558    /// use tvdata_rs::scanner::ScanQuery;
559    /// use tvdata_rs::{Result, TradingViewClient};
560    ///
561    /// #[tokio::main]
562    /// async fn main() -> Result<()> {
563    ///     let client = TradingViewClient::builder().build()?;
564    ///     let query = ScanQuery::new()
565    ///         .markets(["america", "crypto"])
566    ///         .select([price::CLOSE, fundamentals::MARKET_CAP_BASIC]);
567    ///
568    ///     let response = client.scan_supported(&query).await?;
569    ///     println!("rows: {}", response.rows.len());
570    ///     Ok(())
571    /// }
572    /// ```
573    pub async fn scan_supported(&self, query: &ScanQuery) -> Result<ScanResponse> {
574        let (filtered, _) = self.filter_scan_query(query).await?;
575        self.scan(&filtered).await
576    }
577
578    /// Fetches TradingView scanner metainfo for a specific market or screener.
579    ///
580    /// This endpoint returns the currently supported field names and their value types
581    /// as exposed by TradingView for the selected screener route.
582    ///
583    /// # Examples
584    ///
585    /// ```no_run
586    /// use tvdata_rs::{Result, TradingViewClient};
587    ///
588    /// #[tokio::main]
589    /// async fn main() -> Result<()> {
590    ///     let client = TradingViewClient::builder().build()?;
591    ///     let metainfo = client.metainfo("america").await?;
592    ///
593    ///     println!("fields: {}", metainfo.fields.len());
594    ///     Ok(())
595    /// }
596    /// ```
597    pub async fn metainfo(&self, market: impl Into<Market>) -> Result<ScannerMetainfo> {
598        let market = market.into();
599        self.cached_metainfo(&market).await
600    }
601
602    /// Searches TradingView symbol metadata using the symbol search endpoint.
603    ///
604    /// # Examples
605    ///
606    /// ```no_run
607    /// use tvdata_rs::{Result, SearchRequest, TradingViewClient};
608    ///
609    /// #[tokio::main]
610    /// async fn main() -> Result<()> {
611    ///     let client = TradingViewClient::builder().build()?;
612    ///     let hits = client
613    ///         .search(&SearchRequest::builder().text("AAPL").build())
614    ///         .await?;
615    ///
616    ///     println!("matches: {}", hits.len());
617    ///     Ok(())
618    /// }
619    /// ```
620    pub async fn search(&self, request: &SearchRequest) -> Result<Vec<SearchHit>> {
621        Ok(self.search_response(request).await?.hits)
622    }
623
624    /// Searches equities using TradingView's current `search_type=stock` filter.
625    pub async fn search_equities(&self, text: impl Into<String>) -> Result<Vec<SearchHit>> {
626        Ok(self.search_equities_response(text).await?.hits)
627    }
628
629    /// Searches equities and returns the richer v3 response envelope.
630    pub async fn search_equities_response(
631        &self,
632        text: impl Into<String>,
633    ) -> Result<SearchResponse> {
634        self.search_response(&SearchRequest::equities(text)).await
635    }
636
637    /// Searches forex instruments using TradingView's current `search_type=forex` filter.
638    pub async fn search_forex(&self, text: impl Into<String>) -> Result<Vec<SearchHit>> {
639        Ok(self.search_forex_response(text).await?.hits)
640    }
641
642    /// Searches forex instruments and returns the richer v3 response envelope.
643    pub async fn search_forex_response(&self, text: impl Into<String>) -> Result<SearchResponse> {
644        self.search_response(&SearchRequest::forex(text)).await
645    }
646
647    /// Searches crypto instruments using TradingView's current `search_type=crypto` filter.
648    pub async fn search_crypto(&self, text: impl Into<String>) -> Result<Vec<SearchHit>> {
649        Ok(self.search_crypto_response(text).await?.hits)
650    }
651
652    /// Searches crypto instruments and returns the richer v3 response envelope.
653    pub async fn search_crypto_response(&self, text: impl Into<String>) -> Result<SearchResponse> {
654        self.search_response(&SearchRequest::crypto(text)).await
655    }
656
657    /// Searches option-like instruments.
658    ///
659    /// As of March 22, 2026, TradingView's live `symbol_search/v3` endpoint rejects
660    /// `search_type=option`, so this method performs a broader search and then keeps
661    /// hits that look option-related based on the returned payload.
662    pub async fn search_options(&self, text: impl Into<String>) -> Result<Vec<SearchHit>> {
663        Ok(self.search_options_response(text).await?.hits)
664    }
665
666    /// Searches option-like instruments and returns the filtered v3 response envelope.
667    pub async fn search_options_response(&self, text: impl Into<String>) -> Result<SearchResponse> {
668        let response = self.search_response(&SearchRequest::options(text)).await?;
669        Ok(response.filtered(SearchHit::is_option_like))
670    }
671
672    /// Searches TradingView symbol metadata and returns the richer v3 search envelope.
673    ///
674    /// This includes the remaining symbol count reported by TradingView, plus richer
675    /// instrument metadata such as identifiers and listing/source information.
676    ///
677    /// # Examples
678    ///
679    /// ```no_run
680    /// use tvdata_rs::{Result, SearchRequest, TradingViewClient};
681    ///
682    /// #[tokio::main]
683    /// async fn main() -> Result<()> {
684    ///     let client = TradingViewClient::builder().build()?;
685    ///     let response = client
686    ///         .search_response(&SearchRequest::builder().text("AAPL").build())
687    ///         .await?;
688    ///
689    ///     println!("hits: {}", response.hits.len());
690    ///     println!("remaining: {}", response.symbols_remaining);
691    ///     Ok(())
692    /// }
693    /// ```
694    pub async fn search_response(&self, request: &SearchRequest) -> Result<SearchResponse> {
695        if request.text.trim().is_empty() {
696            return Err(Error::EmptySearchQuery);
697        }
698
699        let raw: RawSearchResponse = self
700            .execute_json(
701                self.http
702                    .get(self.endpoints.symbol_search_base_url.clone())
703                    .query(&request.to_query_pairs()),
704            )
705            .await?;
706
707        Ok(sanitize_response(raw))
708    }
709
710    /// Fetches economic calendar events from TradingView's Reuters-backed calendar feed.
711    ///
712    /// # Examples
713    ///
714    /// ```no_run
715    /// use tvdata_rs::{EconomicCalendarRequest, Result, TradingViewClient};
716    ///
717    /// #[tokio::main]
718    /// async fn main() -> Result<()> {
719    ///     let client = TradingViewClient::builder().build()?;
720    ///     let response = client
721    ///         .economic_calendar(&EconomicCalendarRequest::upcoming(7))
722    ///         .await?;
723    ///
724    ///     println!("events: {}", response.events.len());
725    ///     Ok(())
726    /// }
727    /// ```
728    pub async fn economic_calendar(
729        &self,
730        request: &EconomicCalendarRequest,
731    ) -> Result<EconomicCalendarResponse> {
732        let raw: RawEconomicCalendarResponse = self
733            .execute_json(
734                self.http
735                    .get(self.endpoints.calendar_base_url().clone())
736                    .query(&request.to_query_pairs()?),
737            )
738            .await?;
739
740        Ok(sanitize_calendar(raw))
741    }
742
743    /// Fetches an earnings calendar window from TradingView scanner fields.
744    ///
745    /// This is a market-wide calendar product, distinct from
746    /// `client.equity().earnings_calendar("NASDAQ:AAPL")`, which returns
747    /// single-symbol analyst earnings metadata.
748    ///
749    /// # Examples
750    ///
751    /// ```no_run
752    /// use tvdata_rs::{CalendarWindowRequest, Result, TradingViewClient};
753    ///
754    /// #[tokio::main]
755    /// async fn main() -> Result<()> {
756    ///     let client = TradingViewClient::builder().build()?;
757    ///     let events = client
758    ///         .earnings_calendar(&CalendarWindowRequest::upcoming("america", 7))
759    ///         .await?;
760    ///
761    ///     println!("events: {}", events.len());
762    ///     Ok(())
763    /// }
764    /// ```
765    pub async fn earnings_calendar(
766        &self,
767        request: &CalendarWindowRequest,
768    ) -> Result<Vec<EarningsCalendarEntry>> {
769        self.corporate_earnings_calendar(request).await
770    }
771
772    /// Fetches a dividend calendar window from TradingView scanner fields.
773    ///
774    /// The request can be anchored either on upcoming ex-dates or upcoming
775    /// payment dates through [`DividendCalendarRequest::date_kind`].
776    ///
777    /// # Examples
778    ///
779    /// ```no_run
780    /// use tvdata_rs::{DividendCalendarRequest, Result, TradingViewClient};
781    ///
782    /// #[tokio::main]
783    /// async fn main() -> Result<()> {
784    ///     let client = TradingViewClient::builder().build()?;
785    ///     let events = client
786    ///         .dividend_calendar(&DividendCalendarRequest::upcoming("america", 14))
787    ///         .await?;
788    ///
789    ///     println!("events: {}", events.len());
790    ///     Ok(())
791    /// }
792    /// ```
793    pub async fn dividend_calendar(
794        &self,
795        request: &DividendCalendarRequest,
796    ) -> Result<Vec<DividendCalendarEntry>> {
797        self.corporate_dividend_calendar(request).await
798    }
799
800    /// Fetches an IPO calendar window from TradingView scanner fields.
801    ///
802    /// # Examples
803    ///
804    /// ```no_run
805    /// use tvdata_rs::{CalendarWindowRequest, Result, TradingViewClient};
806    ///
807    /// #[tokio::main]
808    /// async fn main() -> Result<()> {
809    ///     let client = TradingViewClient::builder().build()?;
810    ///     let events = client
811    ///         .ipo_calendar(&CalendarWindowRequest::trailing("america", 30))
812    ///         .await?;
813    ///
814    ///     println!("events: {}", events.len());
815    ///     Ok(())
816    /// }
817    /// ```
818    pub async fn ipo_calendar(
819        &self,
820        request: &CalendarWindowRequest,
821    ) -> Result<Vec<IpoCalendarEntry>> {
822        self.corporate_ipo_calendar(request).await
823    }
824
825    /// Downloads a single OHLCV history series over TradingView's chart websocket.
826    ///
827    /// # Examples
828    ///
829    /// ```no_run
830    /// use tvdata_rs::{HistoryRequest, Interval, Result, TradingViewClient};
831    ///
832    /// #[tokio::main]
833    /// async fn main() -> Result<()> {
834    ///     let client = TradingViewClient::builder().build()?;
835    ///     let request = HistoryRequest::new("NASDAQ:AAPL", Interval::Day1, 30);
836    ///     let series = client.history(&request).await?;
837    ///
838    ///     println!("bars: {}", series.bars.len());
839    ///     Ok(())
840    /// }
841    /// ```
842    ///
843    /// To fetch the maximum history currently available, construct the request
844    /// with `HistoryRequest::max("NASDAQ:AAPL", Interval::Day1)`.
845    pub async fn history(&self, request: &HistoryRequest) -> Result<HistorySeries> {
846        fetch_history(
847            &self.endpoints,
848            &self.auth_token,
849            &self.user_agent,
850            self.session_id(),
851            request,
852        )
853        .await
854    }
855
856    async fn execute_json<T>(&self, request: RequestBuilder) -> Result<T>
857    where
858        T: DeserializeOwned,
859    {
860        let body = self.execute_text(request).await?;
861        serde_json::from_str(&body).map_err(Into::into)
862    }
863
864    async fn execute_text(&self, request: RequestBuilder) -> Result<String> {
865        let response = request.send().await?;
866        let status = response.status();
867        let body = response.text().await?;
868
869        if !status.is_success() {
870            return Err(Error::ApiStatus { status, body });
871        }
872
873        Ok(body)
874    }
875
876    async fn cached_metainfo(&self, market: &Market) -> Result<ScannerMetainfo> {
877        if let Some(cached) = self
878            .metainfo_cache
879            .read()
880            .await
881            .get(market.as_str())
882            .cloned()
883        {
884            return Ok(cached);
885        }
886
887        let metainfo: ScannerMetainfo = self
888            .execute_json(self.http.get(self.endpoints.scanner_metainfo_url(market)?))
889            .await?;
890
891        self.metainfo_cache
892            .write()
893            .await
894            .insert(market.as_str().to_owned(), metainfo.clone());
895
896        Ok(metainfo)
897    }
898}
899
900fn validation_markets(query: &ScanQuery) -> Result<Vec<Market>> {
901    if query.markets.is_empty() {
902        return Err(Error::ScanValidationUnavailable {
903            reason: "query does not specify any markets".to_owned(),
904        });
905    }
906
907    Ok(query.markets.clone())
908}
909
910fn supports_column_for_market(market: &Market, metainfo: &ScannerMetainfo, column: &str) -> bool {
911    metainfo.supports_field(column)
912        || market_to_screener_kind(market)
913            .and_then(|kind| embedded_registry().find_by_api_name(kind, column))
914            .is_some()
915}
916
917fn market_to_screener_kind(market: &Market) -> Option<ScreenerKind> {
918    match market.as_str() {
919        "crypto" => Some(ScreenerKind::Crypto),
920        "forex" => Some(ScreenerKind::Forex),
921        "bond" | "bonds" => Some(ScreenerKind::Bond),
922        "futures" => Some(ScreenerKind::Futures),
923        "coin" => Some(ScreenerKind::Coin),
924        "options" | "economics2" | "cfd" => None,
925        _ => Some(ScreenerKind::Stock),
926    }
927}
928
929#[cfg(test)]
930mod tests;