Skip to main content

tvscreener/core/
mod.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Asset-class screeners and shared [`Screener`] builder state.
5//!
6//! [`Screener::build_payload`] assembles the POST body; [`Screener::get`] sends it and
7//! decodes rows. Filters with the same `left` key merge: new values append and the
8//! operator promotes to [`FilterOperator::InRange`] when values are not a subset.
9
10#[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
32/// Default result window start
33pub const DEFAULT_MIN_RANGE: u32 = 0;
34
35/// Default result window end
36pub 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
48/// Truncates an HTTP error body for [`TvscreenerError::HttpStatus`] display.
49pub(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/// Request builder for one scanner POST.
61///
62/// Holds columns, filters, range, sort, markets/symbols, and a reused
63/// [`reqwest::Client`]. [`Self::build_payload`] builds the JSON body;
64/// [`Self::get`] POSTs to `scanner.tradingview.com/{subtype}/scan` and returns
65/// [`ScreenerRow`]s. Prefer typed wrappers (`StockScreener`, `CryptoScreener`, …)
66/// which set subtype and defaults; use this type when you need the raw builder.
67///
68/// Override the timeout with [`Self::set_timeout`] before [`Self::get`].
69///
70/// # Payload example
71///
72/// ```
73/// use tvscreener::core::Screener;
74/// use tvscreener::field::{index_symbol, FieldDef, Market};
75/// use tvscreener::filter::{FieldCondition, FilterOperator};
76/// use serde_json::json;
77///
78/// let mut screener = Screener::new("global");
79/// screener
80///.select([FieldDef::new("Name", "name")])
81///.set_markets([Market::america().value])
82///.set_index([index_symbol::SP500])
83///.where_condition(FieldCondition::new(
84/// "close",
85/// FilterOperator::Above,
86/// json!(50),
87/// )).unwrap()
88///.set_range(0, 10);
89/// let payload = screener.build_payload().unwrap();
90/// assert_eq!(payload["markets"], json!(["america"]));
91/// assert_eq!(payload["symbols"]["symbolset"], json!(["SYML:SP;SPX"]));
92/// ```
93#[derive(Debug, Clone)]
94pub struct Screener {
95    /// Path segment in `https://scanner.tradingview.com/{subtype}/scan`.
96    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    /// When true, filter `left` keys must appear in the allowed field set.
110    validate_filter_fields: bool,
111    markets: Option<Vec<String>>,
112    timeout: Duration,
113    client: reqwest::Client,
114}
115
116impl Screener {
117    /// Creates a screener targeting `subtype` (e.g. `"crypto"`, `"global"` for stocks).
118    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    /// Overrides the HTTP request timeout (default: [`DEFAULT_TIMEOUT_SECS`]).
145    ///
146    /// Rebuilds the shared [`reqwest::Client`] so the new timeout applies to later
147    /// [`Self::get`] / stream calls.
148    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    /// Returns the configured HTTP timeout.
155    #[must_use]
156    pub const fn timeout(&self) -> Duration {
157        self.timeout
158    }
159
160    /// Overrides the POST URL (defaults to [`crate::util::get_url`] for [`Self::subtype`]).
161    pub fn set_url(&mut self, url: impl Into<String>) -> &mut Self {
162        self.url = Some(url.into());
163        self
164    }
165
166    /// Registers the callback used by [`Self::select_all`]
167    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    /// Restricts requested columns, replacing any prior selection
173    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    /// Requests the field set registered by [`Self::set_all_fields_fn`]
180    ///
181    /// # Errors
182    ///
183    /// Returns [`TvscreenerError::InvalidRequest`] when `set_all_fields_fn` was not called.
184    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, &current.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    /// Returns whether `left` is allowed when [`Self::validate_filter_fields`] is enabled.
218    ///
219    /// Always allows [`ExtraFilter`] keys and stock structural keys `type` / `subtype`.
220    /// Otherwise requires a match against [`Self::set_all_fields_fn`] defaults when set,
221    /// else the currently selected columns' technical names.
222    #[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    /// Enables or disables filter-field validation (default: off).
252    pub const fn set_validate_filter_fields(&mut self, enable: bool) -> &mut Self {
253        self.validate_filter_fields = enable;
254        self
255    }
256
257    /// Returns whether filter `left` keys are validated against the allowed field set.
258    #[must_use]
259    pub const fn validate_filter_fields(&self) -> bool {
260        self.validate_filter_fields
261    }
262
263    /// Appends or merges a filter clause.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`TvscreenerError::InvalidFieldName`] when validation is enabled and `left`
268    /// is not in the allowed set.
269    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    /// Appends or merges a filter on an [`ExtraFilter`] left key.
287    ///
288    /// # Errors
289    ///
290    /// Propagates [`Self::add_filter`] errors (extra keys are always allowed).
291    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    /// Appends or merges a filter from a [`FieldCondition`].
302    ///
303    /// # Errors
304    ///
305    /// Propagates [`Self::add_filter`] errors.
306    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    /// Adds a name/description search filter.
312    ///
313    /// # Errors
314    ///
315    /// Propagates [`Self::add_extra_filter`] errors.
316    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    /// Removes the first filter whose `left` matches
325    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    /// Sets the `[from, to)` result window
333    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    /// Sets sort column and direction by technical field name
340    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    /// Sets sort column from a [`FieldDef`] (uses [`FieldDef::field_name`]).
346    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    /// Enables debug logging of scan URL and JSON payload (via `tracing` at DEBUG).
351    ///
352    /// Also enabled for the process when `TVSCREENER_DEBUG=1` (payload dump) after
353    /// [`crate::logging::init_logging`], or when `RUST_LOG` includes `tvscreener=debug`.
354    pub const fn set_debug(&mut self, enable: bool) -> &mut Self {
355        self.print_request = enable;
356        self
357    }
358
359    /// Alias for [`Self::set_debug`].
360    pub const fn set_print_request(&mut self, enable: bool) -> &mut Self {
361        self.set_debug(enable)
362    }
363
364    /// Adds one scan `options` entry
365    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    /// Adds one top-level payload key outside `filter`/`columns`
371    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    /// Restricts results to index symbol sets.
377    ///
378    /// Catalog values like `"SP;SPX"` are sent as `"SYML:SP;SPX"`. Values already prefixed with `SYML:` are
379    /// left unchanged.
380    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    /// Sets the `symbols` block on the payload
403    pub fn set_symbols(&mut self, symbols: Value) -> &mut Self {
404        self.symbols = Some(symbols);
405        self
406    }
407
408    /// Sets market filters included as top-level `"markets"` (stock screener).
409    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    /// Returns configured filters (read-only view for tests and debugging).
418    #[must_use]
419    pub fn filters(&self) -> &[Filter] {
420        &self.filters
421    }
422
423    /// Returns whether `select_all` was enabled.
424    #[must_use]
425    pub const fn is_select_all(&self) -> bool {
426        self.select_all_fields
427    }
428
429    /// Returns the configured result range `(from, to)`.
430    #[must_use]
431    pub const fn range(&self) -> (u32, u32) {
432        (self.range_from, self.range_to)
433    }
434
435    /// Returns the selected field definitions.
436    #[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    /// Builds the JSON POST body
464    ///
465    /// # Errors
466    ///
467    /// Returns [`TvscreenerError::InvalidRequest`] when no fields were selected.
468    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    /// Parses a scanner JSON body into [`ScreenerRow`] values keyed by column labels.
497    ///
498    /// # Errors
499    ///
500    /// Returns [`TvscreenerError::Json`] when `data`, row symbols, or row value arrays
501    /// are missing or malformed.
502    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    /// Runs the scan and returns decoded rows
530    ///
531    /// # Errors
532    ///
533    /// Propagates [`TvscreenerError::InvalidRequest`], HTTP/network failures,
534    /// non-success status codes, JSON parse errors, and row decode failures.
535    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    /// Polls [`Self::get`] on an interval, invoking `on_update` for each successful batch.
568    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    /// Polls [`Self::get`] and returns each successful batch (errors are logged and skipped).
600    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
614/// Configures a typed screener for `asset` and returns the result of `f`.
615///
616/// # Errors
617///
618/// Propagates errors from `f` or typed screener construction defaults.
619pub 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
652/// Configures a typed screener for `asset`, then runs [`Screener::get`].
653///
654/// # Errors
655///
656/// Propagates configure or HTTP/scan failures.
657pub 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}