1#[macro_use]
11mod typed;
12
13pub mod bond;
14pub mod coin;
15pub mod crypto;
16pub mod forex;
17pub mod futures;
18pub mod stock;
19
20use crate::error::{Result, TvscreenerError};
21use crate::field::FieldDef;
22use crate::filter::{ExtraFilter, FieldCondition, Filter, FilterOperator};
23use crate::util::{
24 get_columns_to_request, get_url, is_status_code_ok, request_headers, DEFAULT_TIMEOUT_SECS,
25 MIN_STREAM_INTERVAL_SECS,
26};
27use crate::ScreenerRow;
28use serde_json::{json, Map, Value};
29use std::collections::HashMap;
30use std::time::Duration;
31
32pub const DEFAULT_MIN_RANGE: u32 = 0;
34
35pub const DEFAULT_MAX_RANGE: u32 = 150;
37
38fn build_http_client(timeout: Duration) -> reqwest::Client {
39 reqwest::Client::builder()
40 .timeout(timeout)
41 .default_headers(request_headers())
42 .build()
43 .expect("reqwest Client builder must succeed")
44}
45
46const HTTP_ERROR_BODY_MAX: usize = 4096;
47
48pub(crate) fn truncate_http_body(body: String) -> String {
50 if body.len() <= HTTP_ERROR_BODY_MAX {
51 body
52 } else {
53 let mut truncated = body;
54 truncated.truncate(HTTP_ERROR_BODY_MAX);
55 truncated.push('…');
56 truncated
57 }
58}
59
60#[derive(Debug, Clone)]
94pub struct Screener {
95 pub subtype: String,
97 url: Option<String>,
98 filters: Vec<Filter>,
99 options: HashMap<String, Value>,
100 misc: HashMap<String, Value>,
101 symbols: Option<Value>,
102 selected_fields: Vec<FieldDef>,
103 select_all_fields: bool,
104 all_fields_fn: Option<fn() -> Vec<FieldDef>>,
105 range_from: u32,
106 range_to: u32,
107 sort_by: Option<(String, bool)>,
108 print_request: bool,
109 validate_filter_fields: bool,
111 markets: Option<Vec<String>>,
112 timeout: Duration,
113 client: reqwest::Client,
114}
115
116impl Screener {
117 pub fn new(subtype: impl Into<String>) -> Self {
119 let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS);
120 let mut screener = Self {
121 subtype: subtype.into(),
122 url: None,
123 filters: Vec::new(),
124 options: HashMap::new(),
125 misc: HashMap::new(),
126 symbols: None,
127 selected_fields: Vec::new(),
128 select_all_fields: false,
129 all_fields_fn: None,
130 range_from: DEFAULT_MIN_RANGE,
131 range_to: DEFAULT_MAX_RANGE,
132 sort_by: None,
133 print_request: false,
134 validate_filter_fields: false,
135 markets: None,
136 timeout,
137 client: build_http_client(timeout),
138 };
139 screener.add_option("lang", json!("en"));
140 screener.set_range(DEFAULT_MIN_RANGE, DEFAULT_MAX_RANGE);
141 screener
142 }
143
144 pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
149 self.timeout = timeout;
150 self.client = build_http_client(timeout);
151 self
152 }
153
154 #[must_use]
156 pub const fn timeout(&self) -> Duration {
157 self.timeout
158 }
159
160 pub fn set_url(&mut self, url: impl Into<String>) -> &mut Self {
162 self.url = Some(url.into());
163 self
164 }
165
166 pub fn set_all_fields_fn(&mut self, f: fn() -> Vec<FieldDef>) -> &mut Self {
168 self.all_fields_fn = Some(f);
169 self
170 }
171
172 pub fn select(&mut self, fields: impl IntoIterator<Item = FieldDef>) -> &mut Self {
174 self.selected_fields = fields.into_iter().collect();
175 self.select_all_fields = false;
176 self
177 }
178
179 pub fn select_all(&mut self) -> Result<&mut Self> {
185 let f = self.all_fields_fn.ok_or_else(|| {
186 TvscreenerError::InvalidRequest(
187 "select_all requires set_all_fields_fn on this screener".into(),
188 )
189 })?;
190 self.selected_fields = f();
191 self.select_all_fields = true;
192 Ok(self)
193 }
194
195 fn find_filter_index(&self, left: &str) -> Option<usize> {
196 self.filters.iter().position(|f| f.left == left)
197 }
198
199 fn merge_filters(current: &mut Filter, new_filter: &Filter) {
200 if !values_are_subset(&new_filter.values, ¤t.values) {
201 current.operation = FilterOperator::InRange;
202 for value in &new_filter.values {
203 if !current.values.iter().any(|v| v == value) {
204 current.values.push(value.clone());
205 }
206 }
207 }
208 }
209
210 fn add_new_filter(&mut self, mut filter: Filter) {
211 if filter.values.len() == 1 && filter.operation == FilterOperator::InRange {
212 filter.operation = FilterOperator::Equal;
213 }
214 self.filters.push(filter);
215 }
216
217 #[must_use]
223 pub fn filter_left_allowed(&self, left: &str) -> bool {
224 if left == "type"
225 || left == "subtype"
226 || left == ExtraFilter::CurrentTradingDay.field_name()
227 || left == ExtraFilter::Search.field_name()
228 || left == ExtraFilter::Primary.field_name()
229 {
230 return true;
231 }
232 let allowed: Vec<String> = self.all_fields_fn.map_or_else(
233 || {
234 self.selected_fields
235 .iter()
236 .map(|field| field.field_name.clone())
237 .collect()
238 },
239 |f| f().into_iter().map(|field| field.field_name).collect(),
240 );
241 allowed.iter().any(|name| name == left)
242 }
243
244 fn ensure_filter_left_allowed(&self, left: &str) -> Result<()> {
245 if !self.validate_filter_fields || self.filter_left_allowed(left) {
246 return Ok(());
247 }
248 Err(TvscreenerError::InvalidFieldName(left.to_string()))
249 }
250
251 pub const fn set_validate_filter_fields(&mut self, enable: bool) -> &mut Self {
253 self.validate_filter_fields = enable;
254 self
255 }
256
257 #[must_use]
259 pub const fn validate_filter_fields(&self) -> bool {
260 self.validate_filter_fields
261 }
262
263 pub fn add_filter(
270 &mut self,
271 left: impl Into<String>,
272 operation: FilterOperator,
273 values: impl IntoIterator<Item = Value>,
274 ) -> Result<&mut Self> {
275 let left = left.into();
276 self.ensure_filter_left_allowed(&left)?;
277 let new_filter = Filter::new(left.clone(), operation, values);
278 if let Some(index) = self.find_filter_index(&left) {
279 Self::merge_filters(&mut self.filters[index], &new_filter);
280 } else {
281 self.add_new_filter(new_filter);
282 }
283 Ok(self)
284 }
285
286 pub fn add_extra_filter(
292 &mut self,
293 extra: ExtraFilter,
294 operation: FilterOperator,
295 values: impl IntoIterator<Item = Value>,
296 ) -> Result<&mut Self> {
297 let filter = Filter::on_extra(extra, operation, values);
298 self.add_filter(filter.left, filter.operation, filter.values)
299 }
300
301 pub fn where_condition(&mut self, condition: FieldCondition) -> Result<&mut Self> {
307 let filter = condition.into_filter();
308 self.add_filter(filter.left, filter.operation, filter.values)
309 }
310
311 pub fn search(&mut self, value: impl Into<String>) -> Result<&mut Self> {
317 self.add_extra_filter(
318 ExtraFilter::Search,
319 FilterOperator::Match,
320 [json!(value.into())],
321 )
322 }
323
324 pub fn remove_filter(&mut self, left: &str) -> &mut Self {
326 if let Some(index) = self.find_filter_index(left) {
327 self.filters.remove(index);
328 }
329 self
330 }
331
332 pub const fn set_range(&mut self, from_range: u32, to_range: u32) -> &mut Self {
334 self.range_from = from_range;
335 self.range_to = to_range;
336 self
337 }
338
339 pub fn sort_by(&mut self, field_name: impl Into<String>, ascending: bool) -> &mut Self {
341 self.sort_by = Some((field_name.into(), ascending));
342 self
343 }
344
345 pub fn sort_by_field(&mut self, field: &FieldDef, ascending: bool) -> &mut Self {
347 self.sort_by(field.field_name.as_str(), ascending)
348 }
349
350 pub const fn set_debug(&mut self, enable: bool) -> &mut Self {
355 self.print_request = enable;
356 self
357 }
358
359 pub const fn set_print_request(&mut self, enable: bool) -> &mut Self {
361 self.set_debug(enable)
362 }
363
364 pub fn add_option(&mut self, key: impl Into<String>, value: Value) -> &mut Self {
366 self.options.insert(key.into(), value);
367 self
368 }
369
370 pub fn add_misc(&mut self, key: impl Into<String>, value: Value) -> &mut Self {
372 self.misc.insert(key.into(), value);
373 self
374 }
375
376 pub fn set_index(
381 &mut self,
382 symbolsets: impl IntoIterator<Item = impl Into<String>>,
383 ) -> &mut Self {
384 let symbolset: Vec<String> = symbolsets
385 .into_iter()
386 .map(|s| crate::field::to_symbolset_wire(&s.into()))
387 .collect();
388 if symbolset.is_empty() {
389 return self;
390 }
391 match &mut self.symbols {
392 Some(Value::Object(map)) => {
393 map.insert("symbolset".into(), json!(symbolset));
394 }
395 _ => {
396 self.symbols = Some(json!({ "symbolset": symbolset }));
397 }
398 }
399 self
400 }
401
402 pub fn set_symbols(&mut self, symbols: Value) -> &mut Self {
404 self.symbols = Some(symbols);
405 self
406 }
407
408 pub fn set_markets(
410 &mut self,
411 markets: impl IntoIterator<Item = impl Into<String>>,
412 ) -> &mut Self {
413 self.markets = Some(markets.into_iter().map(Into::into).collect());
414 self
415 }
416
417 #[must_use]
419 pub fn filters(&self) -> &[Filter] {
420 &self.filters
421 }
422
423 #[must_use]
425 pub const fn is_select_all(&self) -> bool {
426 self.select_all_fields
427 }
428
429 #[must_use]
431 pub const fn range(&self) -> (u32, u32) {
432 (self.range_from, self.range_to)
433 }
434
435 #[must_use]
437 pub fn selected_fields(&self) -> &[FieldDef] {
438 &self.selected_fields
439 }
440
441 fn request_url(&self) -> String {
442 self.url.clone().unwrap_or_else(|| get_url(&self.subtype))
443 }
444
445 fn resolved_fields(&self) -> Result<Vec<FieldDef>> {
446 if self.selected_fields.is_empty() {
447 return Err(TvscreenerError::InvalidRequest(
448 "no fields selected; call select() or select_all()".into(),
449 ));
450 }
451 Ok(self.selected_fields.clone())
452 }
453
454 fn build_sort(&self) -> Option<Value> {
455 self.sort_by.as_ref().map(|(field, ascending)| {
456 json!({
457 "sortBy": field,
458 "sortOrder": if *ascending { "asc" } else { "desc" },
459 })
460 })
461 }
462
463 pub fn build_payload(&self) -> Result<Value> {
469 let fields = self.resolved_fields()?;
470 let columns = get_columns_to_request(&fields);
471 let requested_columns: Vec<&str> = columns.iter().map(|(tech, _)| tech.as_str()).collect();
472
473 let mut payload = json!({
474 "filter": self.filters.iter().map(Filter::to_json).collect::<Vec<_>>(),
475 "options": self.options,
476 "symbols": self.symbols.clone().unwrap_or_else(|| {
477 json!({ "query": { "types": [] }, "tickers": [] })
478 }),
479 "sort": self.build_sort(),
480 "range": [self.range_from, self.range_to],
481 "columns": requested_columns,
482 });
483
484 if let Some(obj) = payload.as_object_mut() {
485 for (key, value) in &self.misc {
486 obj.insert(key.clone(), value.clone());
487 }
488 if let Some(markets) = &self.markets {
489 obj.insert("markets".into(), json!(markets));
490 }
491 }
492
493 Ok(payload)
494 }
495
496 pub fn parse_rows(body: &Value, columns: &[(String, String)]) -> Result<Vec<ScreenerRow>> {
503 let data = body
504 .get("data")
505 .and_then(Value::as_array)
506 .ok_or_else(|| TvscreenerError::Json("response missing data array".into()))?;
507
508 let mut rows = Vec::with_capacity(data.len());
509 for entry in data {
510 let symbol = entry
511 .get("s")
512 .and_then(Value::as_str)
513 .ok_or_else(|| TvscreenerError::Json("row missing symbol".into()))?
514 .to_string();
515 let values = entry
516 .get("d")
517 .and_then(Value::as_array)
518 .ok_or_else(|| TvscreenerError::Json("row missing data array".into()))?;
519
520 let mut map = Map::new();
521 for ((_, label), value) in columns.iter().zip(values.iter()) {
522 map.insert(label.clone(), value.clone());
523 }
524 rows.push(ScreenerRow { symbol, data: map });
525 }
526 Ok(rows)
527 }
528
529 pub async fn get(&self) -> Result<Vec<ScreenerRow>> {
536 let fields = self.resolved_fields()?;
537 let columns = get_columns_to_request(&fields);
538 let payload = self.build_payload()?;
539 let url = self.request_url();
540
541 let debug = self.print_request || crate::logging::env_debug_enabled();
542 if debug {
543 tracing::debug!(%url, "screener POST");
544 tracing::debug!(payload = %serde_json::to_string_pretty(&payload)?, "screener payload");
545 } else {
546 tracing::trace!(%url, "screener POST");
547 }
548
549 let client = &self.client;
550 let response = client.post(&url).json(&payload).send().await?;
551 let status = response.status().as_u16();
552 let body_text = response.text().await?;
553
554 if !is_status_code_ok(status) {
555 tracing::warn!(%status, body_len = body_text.len(), "screener HTTP error");
556 return Err(TvscreenerError::HttpStatus {
557 status,
558 body: truncate_http_body(body_text),
559 });
560 }
561
562 tracing::debug!(%status, body_len = body_text.len(), "screener HTTP ok");
563 let body: Value = serde_json::from_str(&body_text)?;
564 Self::parse_rows(&body, &columns)
565 }
566
567 pub async fn stream_with_callback<F>(
569 &self,
570 interval_secs: f64,
571 max_iterations: Option<usize>,
572 mut on_update: F,
573 ) where
574 F: FnMut(&[ScreenerRow]),
575 {
576 let interval_secs = interval_secs.max(MIN_STREAM_INTERVAL_SECS);
577 let mut iteration = 0usize;
578 loop {
579 if max_iterations.is_some_and(|max| iteration >= max) {
580 break;
581 }
582
583 match self.get().await {
584 Ok(rows) => {
585 tracing::debug!(iteration, rows = rows.len(), "stream batch");
586 on_update(&rows);
587 }
588 Err(err) => tracing::warn!(iteration, error = %err, "stream get failed"),
589 }
590
591 iteration += 1;
592 if max_iterations.is_some_and(|max| iteration >= max) {
593 break;
594 }
595 tokio::time::sleep(Duration::from_secs_f64(interval_secs)).await;
596 }
597 }
598
599 pub async fn stream(
601 &self,
602 interval_secs: f64,
603 max_iterations: Option<usize>,
604 ) -> Vec<Vec<ScreenerRow>> {
605 let mut collected = Vec::new();
606 self.stream_with_callback(interval_secs, max_iterations, |rows| {
607 collected.push(rows.to_vec());
608 })
609 .await;
610 collected
611 }
612}
613
614pub fn with_asset_screener<R, F>(asset: crate::field::Asset, f: F) -> Result<R>
620where
621 F: FnOnce(&mut Screener) -> Result<R>,
622{
623 use crate::field::Asset;
624 match asset {
625 Asset::Stock => {
626 let mut s = stock::StockScreener::new();
627 f(s.inner_mut())
628 }
629 Asset::Crypto => {
630 let mut s = crypto::CryptoScreener::new();
631 f(s.inner_mut())
632 }
633 Asset::Forex => {
634 let mut s = forex::ForexScreener::new();
635 f(s.inner_mut())
636 }
637 Asset::Bond => {
638 let mut s = bond::BondScreener::new();
639 f(s.inner_mut())
640 }
641 Asset::Futures => {
642 let mut s = futures::FuturesScreener::new();
643 f(s.inner_mut())
644 }
645 Asset::Coin => {
646 let mut s = coin::CoinScreener::new();
647 f(s.inner_mut())
648 }
649 }
650}
651
652pub async fn get_for_asset<F>(asset: crate::field::Asset, configure: F) -> Result<Vec<ScreenerRow>>
658where
659 F: FnOnce(&mut Screener) -> Result<()>,
660{
661 let screener = with_asset_screener(asset, |inner| {
662 configure(inner)?;
663 Ok(inner.clone())
664 })?;
665 screener.get().await
666}
667
668fn values_are_subset(candidate: &[Value], current: &[Value]) -> bool {
669 candidate
670 .iter()
671 .all(|value| current.iter().any(|v| v == value))
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use crate::field::{crypto_rsi_14, default_crypto_fields};
678 use crate::filter::ExtraFilter;
679 use serde_json::json;
680
681 #[test]
682 fn builder_stores_range_and_filter() {
683 let mut s = Screener::new("crypto");
684 s.set_range(10, 20)
685 .where_condition(FieldCondition::new(
686 "volume",
687 FilterOperator::Above,
688 json!(1_000_000),
689 ))
690 .unwrap();
691 assert_eq!(s.range(), (10, 20));
692 assert_eq!(s.filters().len(), 1);
693 }
694
695 #[test]
696 fn merge_same_left_extends_values_and_promotes_in_range() {
697 let mut s = Screener::new("crypto");
698 s.add_filter("type", FilterOperator::Equal, [json!("stock")])
699 .unwrap();
700 s.add_filter("type", FilterOperator::Equal, [json!("fund")])
701 .unwrap();
702 assert_eq!(s.filters().len(), 1);
703 assert_eq!(s.filters()[0].operation, FilterOperator::InRange);
704 assert_eq!(s.filters()[0].values.len(), 2);
705 }
706
707 #[test]
708 fn single_in_range_becomes_equal() {
709 let mut s = Screener::new("crypto");
710 s.add_filter("type", FilterOperator::InRange, [json!("stock")])
711 .unwrap();
712 assert_eq!(s.filters()[0].operation, FilterOperator::Equal);
713 }
714
715 #[test]
716 fn search_adds_match_filter() {
717 let mut s = Screener::new("crypto");
718 s.search("btc").unwrap();
719 assert_eq!(s.filters()[0].left, ExtraFilter::Search.field_name());
720 assert_eq!(s.filters()[0].operation, FilterOperator::Match);
721 }
722
723 #[test]
724 fn build_payload_crypto_defaults_shape() {
725 let mut s = Screener::new("crypto");
726 s.select(default_crypto_fields())
727 .add_filter("name", FilterOperator::Match, [json!("btc")])
728 .unwrap()
729 .sort_by("24h_vol|5", false)
730 .set_range(0, 5);
731 let payload = s.build_payload().expect("payload");
732 assert!(payload.get("filter").and_then(Value::as_array).is_some());
733 assert!(payload.get("columns").and_then(Value::as_array).is_some());
734 assert_eq!(payload["range"], json!([0, 5]));
735 assert_eq!(payload["options"]["lang"], json!("en"));
736 }
737
738 #[test]
739 fn build_payload_includes_markets_when_set() {
740 let mut s = Screener::new("global");
741 s.select([FieldDef::new("Name", "name")])
742 .set_markets(["america"]);
743 let payload = s.build_payload().expect("payload");
744 assert_eq!(payload["markets"], json!(["america"]));
745 }
746
747 #[test]
748 fn select_replaces_previous_fields() {
749 let mut s = Screener::new("crypto");
750 s.select([FieldDef::new("Name", "name")]);
751 s.select([FieldDef::new("Price", "close")]);
752 assert_eq!(s.selected_fields().len(), 1);
753 assert_eq!(s.selected_fields()[0].field_name, "close");
754 }
755
756 #[test]
757 fn timeout_default_and_override() {
758 let mut s = Screener::new("crypto");
759 assert_eq!(s.timeout(), Duration::from_secs(DEFAULT_TIMEOUT_SECS));
760 s.set_timeout(Duration::from_secs(5));
761 assert_eq!(s.timeout(), Duration::from_secs(5));
762 }
763
764 #[test]
765 fn sort_by_field_uses_technical_name() {
766 let mut s = Screener::new("crypto");
767 let rsi = crypto_rsi_14();
768 s.select([rsi.clone()]).sort_by_field(&rsi, true);
769 let payload = s.build_payload().expect("payload");
770 assert_eq!(payload["sort"]["sortBy"], json!("RSI"));
771 assert_eq!(payload["sort"]["sortOrder"], json!("asc"));
772 }
773
774 #[test]
775 fn validate_filter_fields_rejects_unknown_left() {
776 let mut s = Screener::new("crypto");
777 s.set_all_fields_fn(default_crypto_fields)
778 .select(default_crypto_fields())
779 .set_validate_filter_fields(true);
780 let err = s
781 .add_filter("not_a_real_field_xyz", FilterOperator::Equal, [json!(1)])
782 .expect_err("unknown left");
783 assert!(matches!(err, TvscreenerError::InvalidFieldName(_)));
784 s.add_filter("name", FilterOperator::Match, [json!("btc")])
785 .expect("name is in crypto defaults");
786 s.search("eth").expect("search extra filter always allowed");
787 s.add_filter("type", FilterOperator::Equal, [json!("stock")])
788 .expect("structural type always allowed");
789 }
790
791 #[test]
792 fn set_index_symbolset_wire_shape() {
793 let mut s = Screener::new("global");
794 s.select([FieldDef::new("Name", "name")])
795 .set_index([crate::field::index_symbol::SP500]);
796 let payload = s.build_payload().expect("payload");
797 assert_eq!(payload["symbols"]["symbolset"], json!(["SYML:SP;SPX"]));
798 }
799
800 #[test]
801 fn set_index_does_not_double_prefix_syml() {
802 let mut s = Screener::new("global");
803 s.select([FieldDef::new("Name", "name")])
804 .set_index(["SYML:SP;SPX", crate::field::index_symbol::NASDAQ_100]);
805 let payload = s.build_payload().expect("payload");
806 assert_eq!(
807 payload["symbols"]["symbolset"],
808 json!(["SYML:SP;SPX", "SYML:NASDAQ;NDX"])
809 );
810 }
811
812 #[test]
813 fn truncate_http_body_caps_at_4kib() {
814 let short = truncate_http_body("ok".into());
815 assert_eq!(short, "ok");
816 let long = "x".repeat(HTTP_ERROR_BODY_MAX + 10);
817 let truncated = truncate_http_body(long);
818 assert_eq!(truncated.len(), HTTP_ERROR_BODY_MAX + '…'.len_utf8());
819 assert!(truncated.ends_with('…'));
820 }
821
822 #[test]
823 fn with_asset_screener_builds_typed_payload() {
824 let payload = with_asset_screener(crate::field::Asset::Crypto, |s| {
825 s.set_range(0, 3);
826 s.build_payload()
827 })
828 .expect("payload");
829 assert_eq!(payload["range"], json!([0, 3]));
830 assert!(payload["columns"].as_array().unwrap().len() > 1);
831 }
832
833 #[test]
834 fn stock_set_markets_all_expands_catalog() {
835 let mut ss = stock::StockScreener::new();
836 ss.set_markets([crate::field::Market::all()]);
837 let payload = ss.inner().build_payload().expect("payload");
838 let markets = payload["markets"].as_array().expect("markets");
839 assert!(markets.len() > 10, "ALL should expand; got {markets:?}");
840 }
841
842 #[test]
843 fn select_all_without_callback_is_invalid() {
844 let mut s = Screener::new("crypto");
845 let err = s.select_all().expect_err("no set_all_fields_fn");
846 assert!(matches!(err, TvscreenerError::InvalidRequest(_)));
847 }
848
849 #[tokio::test]
850 async fn get_for_asset_unreachable_is_network_or_timeout() {
851 use std::error::Error;
852
853 let err = get_for_asset(crate::field::Asset::Crypto, |s| {
854 s.set_url("http://127.0.0.1:1/").set_range(0, 1);
855 Ok(())
856 })
857 .await
858 .expect_err("connection to :1 should fail");
859 match &err {
860 TvscreenerError::Network(_) => {
861 assert!(err.source().is_some());
862 }
863 TvscreenerError::Timeout => {}
864 other => panic!("unexpected error: {other:?}"),
865 }
866 }
867}