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#[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#[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 #[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 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 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 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 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 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 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 pub async fn search(&self, request: &SearchRequest) -> Result<Vec<SearchHit>> {
621 Ok(self.search_response(request).await?.hits)
622 }
623
624 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 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 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 pub async fn search_forex_response(&self, text: impl Into<String>) -> Result<SearchResponse> {
644 self.search_response(&SearchRequest::forex(text)).await
645 }
646
647 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 pub async fn search_crypto_response(&self, text: impl Into<String>) -> Result<SearchResponse> {
654 self.search_response(&SearchRequest::crypto(text)).await
655 }
656
657 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 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 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 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 pub async fn earnings_calendar(
766 &self,
767 request: &CalendarWindowRequest,
768 ) -> Result<Vec<EarningsCalendarEntry>> {
769 self.corporate_earnings_calendar(request).await
770 }
771
772 pub async fn dividend_calendar(
794 &self,
795 request: &DividendCalendarRequest,
796 ) -> Result<Vec<DividendCalendarEntry>> {
797 self.corporate_dividend_calendar(request).await
798 }
799
800 pub async fn ipo_calendar(
819 &self,
820 request: &CalendarWindowRequest,
821 ) -> Result<Vec<IpoCalendarEntry>> {
822 self.corporate_ipo_calendar(request).await
823 }
824
825 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;