1use crate::field::FieldDef;
7use crate::ScreenerRow;
8use serde_json::Value;
9
10pub const SCANNER_ORIGIN: &str = "https://scanner.tradingview.com";
12
13pub const TRADINGVIEW_ORIGIN: &str = "https://www.tradingview.com";
15
16pub const TRADINGVIEW_REFERER: &str = "https://www.tradingview.com/";
18
19pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
21
22pub const MIN_STREAM_INTERVAL_SECS: f64 = 1.0;
24
25pub 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#[must_use]
32pub fn get_url(subtype: &str) -> String {
33 format!("{SCANNER_ORIGIN}/{subtype}/scan")
34}
35
36pub 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#[must_use]
50pub fn is_status_code_ok(status: u16) -> bool {
51 (200..300).contains(&status)
52}
53
54#[must_use]
56pub fn add_historical(field_name: &str, historical: u32) -> String {
57 format!("{field_name}[{historical}]")
58}
59
60#[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#[must_use]
72pub fn add_rec(field_name: &str) -> String {
73 format!("Rec.{field_name}")
74}
75
76#[must_use]
78pub fn add_rec_to_label(label: &str) -> String {
79 format!("Reco. {label}")
80}
81
82#[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#[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
141const 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
156const 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#[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#[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#[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#[must_use]
211pub fn format_recommendation(rating: f64) -> String {
212 format_rating(rating)
213}
214
215#[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#[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}