Skip to main content

tvscreener/
util.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! URL helpers, request headers, millify, display formatting, and column-name utilities.
5
6use crate::field::FieldDef;
7use crate::ScreenerRow;
8use serde_json::Value;
9
10/// Base URL for scan endpoints: `{SCANNER_ORIGIN}/{subtype}/scan`.
11pub const SCANNER_ORIGIN: &str = "https://scanner.tradingview.com";
12
13/// `Origin` header value
14pub const TRADINGVIEW_ORIGIN: &str = "https://www.tradingview.com";
15
16/// `Referer` header value (`TRADINGVIEW_ORIGIN` + trailing `/`).
17pub const TRADINGVIEW_REFERER: &str = "https://www.tradingview.com/";
18
19/// Default HTTP timeout for screener POSTs (seconds).
20pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
21
22/// Minimum stream refresh interval (seconds).
23pub const MIN_STREAM_INTERVAL_SECS: f64 = 1.0;
24
25/// Default `User-Agent` for scanner POSTs.
26pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
27
28const MILL_NAMES: [&str; 5] = ["", "K", "M", "B", "T"];
29
30/// Build the scan URL for a screener subtype (`"crypto"`, `"global"`, …).
31#[must_use]
32pub fn get_url(subtype: &str) -> String {
33    format!("{SCANNER_ORIGIN}/{subtype}/scan")
34}
35
36/// HTTP headers for `TradingView` scanner POSTs.
37pub fn request_headers() -> reqwest::header::HeaderMap {
38    use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, ORIGIN, REFERER, USER_AGENT};
39
40    let mut headers = HeaderMap::new();
41    headers.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
42    headers.insert(ORIGIN, HeaderValue::from_static(TRADINGVIEW_ORIGIN));
43    headers.insert(REFERER, HeaderValue::from_static(TRADINGVIEW_REFERER));
44    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
45    headers
46}
47
48/// Returns true when `status` is in the HTTP 2xx range
49#[must_use]
50pub fn is_status_code_ok(status: u16) -> bool {
51    (200..300).contains(&status)
52}
53
54/// Appends a historical lookback suffix
55#[must_use]
56pub fn add_historical(field_name: &str, historical: u32) -> String {
57    format!("{field_name}[{historical}]")
58}
59
60/// Label for a historical column
61#[must_use]
62pub fn add_historical_to_label(label: &str, historical: u32) -> String {
63    if historical == 1 {
64        format!("Prev. {label}")
65    } else {
66        format!("Prev. {historical} {label}")
67    }
68}
69
70/// Recommendation companion column key
71#[must_use]
72pub fn add_rec(field_name: &str) -> String {
73    format!("Rec.{field_name}")
74}
75
76/// Recommendation companion column label
77#[must_use]
78pub fn add_rec_to_label(label: &str) -> String {
79    format!("Reco. {label}")
80}
81
82/// Rewrites timed field names for the wire format
83#[must_use]
84pub fn format_timed_field(field_name: &str) -> String {
85    if (field_name.starts_with("change") || field_name.starts_with("relative_volume_intraday"))
86        && field_name.contains('.')
87    {
88        let Some((_, num)) = field_name.split_once('.') else {
89            return field_name.to_string();
90        };
91        if num.chars().all(|c| c.is_ascii_digit()) || num == "1W" || num == "1M" {
92            return field_name.replace('.', "|");
93        }
94    }
95    field_name.to_string()
96}
97
98/// Assembles `(technical_name, label)` pairs for a scan payload
99#[must_use]
100pub fn get_columns_to_request(fields: &[FieldDef]) -> Vec<(String, String)> {
101    let mut columns: Vec<(String, String)> = Vec::new();
102    let mut seen = std::collections::HashSet::new();
103
104    for field in fields {
105        if field.field_name.starts_with("candlestick") {
106            continue;
107        }
108        let tech = format_timed_field(&field.field_name);
109        if seen.insert(tech.clone()) {
110            columns.push((tech, field.label.clone()));
111        }
112    }
113
114    if seen.insert("update_mode".into()) {
115        columns.push(("update_mode".into(), "Update Mode".into()));
116    }
117
118    for field in fields {
119        if field.has_recommendation() {
120            let tech = add_rec(&field.field_name);
121            let label = add_rec_to_label(&field.label);
122            if seen.insert(tech.clone()) {
123                columns.push((tech, label));
124            }
125        }
126    }
127
128    for field in fields {
129        if field.historical {
130            let tech = add_historical(&field.field_name, 1);
131            let label = add_historical_to_label(&field.label, 1);
132            if seen.insert(tech.clone()) {
133                columns.push((tech, label));
134            }
135        }
136    }
137
138    columns
139}
140
141/// Returns the millify suffix index for a positive magnitude (0 = none, 4 = trillions).
142const fn millify_index(n: f64) -> usize {
143    if n < 1_000.0 {
144        0
145    } else if n < 1_000_000.0 {
146        1
147    } else if n < 1_000_000_000.0 {
148        2
149    } else if n < 1_000_000_000_000.0 {
150        3
151    } else {
152        4
153    }
154}
155
156/// Exponent for `10_f64.powi` given a millify suffix index (0–4).
157const fn millify_pow_exp(millidx: usize) -> i32 {
158    match millidx {
159        0 => 0,
160        1 => 3,
161        2 => 6,
162        3 => 9,
163        _ => 12,
164    }
165}
166
167/// Format a magnitude with K / M / B / T suffixes and three decimal places
168#[must_use]
169pub fn millify(n: f64) -> String {
170    let is_negative = n < 0.0;
171    let n = n.abs();
172
173    let millidx = millify_index(n);
174    let scaled = n / 10_f64.powi(millify_pow_exp(millidx));
175    let result = format!("{scaled:.3}{}", MILL_NAMES[millidx]);
176    if is_negative {
177        format!("-{result}")
178    } else {
179        result
180    }
181}
182
183/// Maps a numeric rating to `"S"`, `"N"`, or `"B"`
184#[must_use]
185pub const fn get_recommendation(rating: f64) -> &'static str {
186    if rating < 0.0 {
187        "S"
188    } else if rating == 0.0 {
189        "N"
190    } else {
191        "B"
192    }
193}
194
195const BUY_CHAR: &str = "↑ B";
196const SELL_CHAR: &str = "↓ S";
197const NEUTRAL_CHAR: &str = "- N";
198
199/// Formats a numeric recommendation as an arrow + letter.
200#[must_use]
201fn format_rating(rating: f64) -> String {
202    match get_recommendation(rating) {
203        "B" => BUY_CHAR.to_string(),
204        "S" => SELL_CHAR.to_string(),
205        _ => NEUTRAL_CHAR.to_string(),
206    }
207}
208
209/// Formats a numeric recommendation as an arrow + letter (`↑ B` / `↓ S` / `- N`).
210#[must_use]
211pub fn format_recommendation(rating: f64) -> String {
212    format_rating(rating)
213}
214
215/// Formats one JSON cell for display; null becomes `"--"`.
216#[must_use]
217pub fn format_value(value: &Value) -> String {
218    match value {
219        Value::Null => "--".into(),
220        Value::String(s) => s.clone(),
221        Value::Bool(b) => b.to_string(),
222        Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), millify),
223        Value::Array(_) | Value::Object(_) => value.to_string(),
224    }
225}
226
227/// Formats a [`ScreenerRow`], optionally limiting to `column_labels`.
228#[must_use]
229pub fn format_row(row: &ScreenerRow, column_labels: Option<&[&str]>) -> String {
230    let mut parts: Vec<String> = column_labels.map_or_else(
231        || {
232            row.data
233                .iter()
234                .map(|(label, value)| format!("{label}: {}", format_value(value)))
235                .collect()
236        },
237        |labels| {
238            labels
239                .iter()
240                .filter_map(|label| {
241                    row.data
242                        .get(*label)
243                        .map(|value| format!("{label}: {}", format_value(value)))
244                })
245                .collect()
246        },
247    );
248    if column_labels.is_none() {
249        parts.sort();
250    }
251    format!("{}  {}", row.symbol, parts.join(", "))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::field::FieldDef;
258    use reqwest::header::{ORIGIN, USER_AGENT};
259    use serde_json::json;
260
261    #[test]
262    fn get_url_joins_subtype() {
263        assert_eq!(
264            get_url("crypto"),
265            "https://scanner.tradingview.com/crypto/scan"
266        );
267    }
268
269    fn sample_field(label: &str, field_name: &str) -> FieldDef {
270        FieldDef {
271            label: label.into(),
272            field_name: field_name.into(),
273            format: None,
274            interval: false,
275            historical: false,
276        }
277    }
278
279    #[test]
280    fn format_timed_field_rewrites_change_intervals() {
281        assert_eq!(format_timed_field("change.15"), "change|15");
282        assert_eq!(format_timed_field("change.1W"), "change|1W");
283        assert_eq!(format_timed_field("close"), "close");
284    }
285
286    #[test]
287    fn get_columns_skips_candlestick_and_adds_update_mode() {
288        let fields = vec![
289            sample_field("Name", "name"),
290            sample_field("Pattern", "candlestick_doji"),
291            FieldDef {
292                label: "Change %".into(),
293                field_name: "change".into(),
294                format: Some("percent".into()),
295                interval: true,
296                historical: false,
297            },
298        ];
299        let cols = get_columns_to_request(&fields);
300        let keys: Vec<_> = cols.iter().map(|(k, _)| k.as_str()).collect();
301        assert!(!keys.iter().any(|k| k.starts_with("candlestick")));
302        assert!(keys.contains(&"update_mode"));
303    }
304
305    #[test]
306    fn get_columns_adds_rec_and_historical() {
307        let fields = vec![
308            FieldDef {
309                label: "RSI".into(),
310                field_name: "RSI".into(),
311                format: Some("recommendation".into()),
312                interval: true,
313                historical: true,
314            },
315            sample_field("Volume", "volume"),
316        ];
317        let cols = get_columns_to_request(&fields);
318        let keys: Vec<_> = cols.iter().map(|(k, _)| k.as_str()).collect();
319        assert!(keys.contains(&"Rec.RSI"));
320        assert!(keys.contains(&"RSI[1]"));
321    }
322
323    #[test]
324    fn millify_three_decimals() {
325        assert_eq!(millify(1_500.0), "1.500K");
326        assert_eq!(millify(2_000_000.0), "2.000M");
327        assert_eq!(millify(42.0), "42.000");
328        assert_eq!(millify(-1_500.0), "-1.500K");
329        assert_eq!(millify(0.0), "0.000");
330    }
331
332    #[test]
333    fn get_recommendation_letters() {
334        assert_eq!(get_recommendation(-1.0), "S");
335        assert_eq!(get_recommendation(0.0), "N");
336        assert_eq!(get_recommendation(1.0), "B");
337    }
338
339    #[test]
340    fn is_status_code_ok_range() {
341        assert!(is_status_code_ok(200));
342        assert!(is_status_code_ok(299));
343        assert!(!is_status_code_ok(404));
344    }
345
346    #[test]
347    fn request_headers_values() {
348        let h = request_headers();
349        assert_eq!(
350            h.get(USER_AGENT).unwrap().to_str().unwrap(),
351            DEFAULT_USER_AGENT
352        );
353        assert_eq!(h.get(ORIGIN).unwrap().to_str().unwrap(), TRADINGVIEW_ORIGIN);
354        assert_eq!(TRADINGVIEW_REFERER, format!("{TRADINGVIEW_ORIGIN}/"));
355        assert_eq!(
356            h.get(reqwest::header::REFERER).unwrap().to_str().unwrap(),
357            TRADINGVIEW_REFERER
358        );
359    }
360
361    #[test]
362    fn format_value_null_is_dash_dash() {
363        assert_eq!(format_value(&json!(null)), "--");
364    }
365
366    #[test]
367    fn format_rating_arrows() {
368        assert_eq!(format_rating(1.0), "↑ B");
369        assert_eq!(format_rating(-1.0), "↓ S");
370        assert_eq!(format_rating(0.0), "- N");
371    }
372
373    #[test]
374    fn format_value_millifies_numbers() {
375        assert_eq!(format_value(&json!(1500.0)), "1.500K");
376    }
377
378    #[test]
379    fn format_row_with_column_filter() {
380        let mut data = serde_json::Map::new();
381        data.insert("Name".into(), json!("BTC"));
382        data.insert("Volume".into(), json!(1_500_000.0));
383        let row = ScreenerRow {
384            symbol: "BINANCE:BTCUSDT".into(),
385            data,
386        };
387        let rendered = format_row(&row, Some(&["Name", "Volume"]));
388        assert!(rendered.contains("BINANCE:BTCUSDT"));
389        assert!(rendered.contains("Name: BTC"));
390        assert!(rendered.contains("Volume: 1.500M"));
391    }
392}