Skip to main content

tvscreener/field/
mod.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Curated field definitions, presets, and metadata loaded from `data/fields.json`.
5//!
6//! Each column is `Field(label, field_name, format, interval, historical)`
7//! in `tvscreener.field`. This crate uses [`FieldDef`] plus a generated catalog.
8
9use crate::error::{Result, TvscreenerError};
10use std::collections::HashMap;
11use std::sync::OnceLock;
12
13/// Asset class key in the generated catalog (`crypto`, `stock`, …).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Asset {
16    /// Cryptocurrency screener fields.
17    Crypto,
18    /// Global stock screener fields.
19    Stock,
20    /// Forex pair fields.
21    Forex,
22    /// Bond fields.
23    Bond,
24    /// Futures contract fields.
25    Futures,
26    /// CEX/DEX coin fields.
27    Coin,
28}
29
30impl Asset {
31    /// Wire/catalog segment for this asset class.
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::Crypto => "crypto",
36            Self::Stock => "stock",
37            Self::Forex => "forex",
38            Self::Bond => "bond",
39            Self::Futures => "futures",
40            Self::Coin => "coin",
41        }
42    }
43
44    /// Parses an asset segment from catalog or preset references.
45    #[must_use]
46    pub fn parse(s: &str) -> Option<Self> {
47        match s {
48            "crypto" => Some(Self::Crypto),
49            "stock" => Some(Self::Stock),
50            "forex" => Some(Self::Forex),
51            "bond" => Some(Self::Bond),
52            "futures" => Some(Self::Futures),
53            "coin" => Some(Self::Coin),
54            _ => None,
55        }
56    }
57}
58
59/// `TradingView` market filter
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Market {
62    /// Catalog constant name (e.g. `"AMERICA"`).
63    pub const_name: String,
64    /// Wire value sent in scan payloads (e.g. `"america"`).
65    pub value: String,
66}
67
68/// Named catalog constant with a single wire/display string (sector, country, …).
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct NamedValue {
71    /// Catalog constant name (e.g. `"TECHNOLOGY_SERVICES"`).
72    pub const_name: String,
73    /// Wire / display value (e.g. `"Technology Services"`).
74    pub value: String,
75}
76
77/// Recommendation rating band (numeric recommendation scores).
78#[derive(Debug, Clone, Copy)]
79pub struct RatingBand {
80    /// Catalog constant name (e.g. `"STRONG_BUY"`).
81    pub const_name: &'static str,
82    /// Inclusive lower bound (`f64::NAN` for [`rating::UNKNOWN`]).
83    pub min: f64,
84    /// Inclusive upper bound (`f64::NAN` for [`rating::UNKNOWN`]).
85    pub max: f64,
86    /// Human label (e.g. `"Strong Buy"`).
87    pub label: &'static str,
88}
89
90impl RatingBand {
91    /// Returns whether `value` falls in `[min, max]` (false when bounds are NaN).
92    #[must_use]
93    pub fn contains(self, value: f64) -> bool {
94        if self.min.is_nan() || self.max.is_nan() || value.is_nan() {
95            return false;
96        }
97        self.min <= value && value <= self.max
98    }
99
100    /// Returns `[min, max]` when both bounds are finite.
101    #[must_use]
102    pub const fn range(self) -> Option<[f64; 2]> {
103        if self.min.is_nan() || self.max.is_nan() {
104            None
105        } else {
106            Some([self.min, self.max])
107        }
108    }
109}
110
111impl Market {
112    /// Convenience constant for the US market.
113    ///
114    /// # Panics
115    ///
116    /// Panics if `AMERICA` is missing from the embedded catalog.
117    #[must_use]
118    pub fn america() -> Self {
119        market("AMERICA").expect("AMERICA market must exist in fields.json")
120    }
121
122    /// Convenience constant for all markets.
123    ///
124    /// # Panics
125    ///
126    /// Panics if `ALL` is missing from the embedded catalog.
127    #[must_use]
128    pub fn all() -> Self {
129        market("ALL").expect("ALL market must exist in fields.json")
130    }
131}
132
133/// Stock `type` filter value
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct TypeFilter {
136    /// Catalog constant name (e.g. `"STOCK"`).
137    pub const_name: String,
138    /// Wire value (e.g. `"stock"`).
139    pub value: String,
140}
141
142/// Stock `subtype` filter value
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct SymbolType {
145    /// Catalog constant name (e.g. `"COMMON_STOCK"`).
146    pub const_name: String,
147    /// Wire values merged into `in_range` filters.
148    pub values: Vec<String>,
149}
150
151/// One requestable scanner column
152///
153/// Field order:
154/// `(label, field_name, format, interval, historical)`.
155#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct FieldDef {
157    /// Human-readable column title
158    pub label: String,
159    /// `TradingView` technical name used in payloads and filters
160    pub field_name: String,
161    /// Optional display/format hint
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub format: Option<String>,
164    /// When true, the field supports time-interval variants
165    pub interval: bool,
166    /// When true, historical lookback columns may be requested
167    pub historical: bool,
168}
169
170impl FieldDef {
171    /// Creates a field with default `format`, `interval`, and `historical` unset/false.
172    pub fn new(label: impl Into<String>, field_name: impl Into<String>) -> Self {
173        Self {
174            label: label.into(),
175            field_name: field_name.into(),
176            format: None,
177            interval: false,
178            historical: false,
179        }
180    }
181
182    /// Returns whether this field uses recommendation format (`format == "recommendation"`).
183    #[must_use]
184    pub fn has_recommendation(&self) -> bool {
185        self.format.as_deref() == Some("recommendation")
186    }
187}
188
189#[derive(serde::Deserialize)]
190struct RawCatalogEntry {
191    label: String,
192    field_name: String,
193    #[serde(default)]
194    format: Option<String>,
195    #[serde(default)]
196    interval: bool,
197    #[serde(default)]
198    historical: bool,
199}
200
201#[derive(serde::Deserialize)]
202struct RawFieldsJson {
203    defaults: HashMap<String, Vec<String>>,
204    catalog: HashMap<String, HashMap<String, RawCatalogEntry>>,
205    presets: HashMap<String, Vec<String>>,
206    markets: Vec<RawMarket>,
207    types: Vec<RawType>,
208    symbol_types: Vec<RawSymbolType>,
209    #[serde(default)]
210    index_symbols: Vec<RawIndexSymbol>,
211    #[serde(default)]
212    sectors: Vec<RawNamedValue>,
213    #[serde(default)]
214    countries: Vec<RawNamedValue>,
215    #[serde(default)]
216    industries: Vec<RawNamedValue>,
217    #[serde(default)]
218    exchanges: Vec<RawNamedValue>,
219}
220
221#[derive(serde::Deserialize)]
222struct RawMarket {
223    #[serde(rename = "const")]
224    const_name: String,
225    value: String,
226}
227
228#[derive(serde::Deserialize)]
229struct RawType {
230    #[serde(rename = "const")]
231    const_name: String,
232    value: String,
233}
234
235#[derive(serde::Deserialize)]
236struct RawSymbolType {
237    #[serde(rename = "const")]
238    const_name: String,
239    value: Vec<String>,
240}
241
242#[derive(serde::Deserialize)]
243struct RawIndexSymbol {
244    #[serde(rename = "const")]
245    const_name: String,
246    value: String,
247    #[serde(default)]
248    label: String,
249}
250
251#[derive(serde::Deserialize)]
252struct RawNamedValue {
253    #[serde(rename = "const")]
254    const_name: String,
255    value: String,
256}
257
258/// One index filter entry
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct IndexSymbolDef {
261    /// Catalog constant name (e.g. `"SP500"`).
262    pub const_name: String,
263    /// Catalog symbol (e.g. `"SP;SPX"`); payload uses [`to_symbolset_wire`].
264    pub value: String,
265    /// Human label (e.g. `"S&P 500"`).
266    pub label: String,
267}
268
269struct FieldsCatalog {
270    defaults: HashMap<String, Vec<String>>,
271    catalog: HashMap<String, HashMap<String, FieldDef>>,
272    presets: HashMap<String, Vec<String>>,
273    markets: Vec<Market>,
274    types: Vec<TypeFilter>,
275    symbol_types: Vec<SymbolType>,
276    index_symbols: Vec<IndexSymbolDef>,
277    sectors: Vec<NamedValue>,
278    countries: Vec<NamedValue>,
279    industries: Vec<NamedValue>,
280    exchanges: Vec<NamedValue>,
281}
282
283fn map_named(raw: Vec<RawNamedValue>) -> Vec<NamedValue> {
284    raw.into_iter()
285        .map(|n| NamedValue {
286            const_name: n.const_name,
287            value: n.value,
288        })
289        .collect()
290}
291
292fn find_named<'a>(items: &'a [NamedValue], const_name: &str) -> Option<&'a NamedValue> {
293    items.iter().find(|n| n.const_name == const_name)
294}
295
296fn named_value_str<'a>(items: &'a [NamedValue], const_name: &str) -> Option<&'a str> {
297    find_named(items, const_name).map(|n| n.value.as_str())
298}
299
300fn resolve_named_wire<'a>(items: &'a [NamedValue], token: &str) -> Option<&'a str> {
301    let upper = token.to_ascii_uppercase().replace(' ', "_");
302    if let Some(v) = named_value_str(items, &upper) {
303        return Some(v);
304    }
305    items
306        .iter()
307        .find(|n| n.value.eq_ignore_ascii_case(token))
308        .map(|n| n.value.as_str())
309}
310
311impl FieldsCatalog {
312    fn load() -> Self {
313        let raw: RawFieldsJson = serde_json::from_str(include_str!("../../data/fields.json"))
314            .expect("fields.json must deserialize");
315
316        let catalog = raw
317            .catalog
318            .into_iter()
319            .map(|(asset, entries)| {
320                let mapped = entries
321                    .into_iter()
322                    .map(|(name, entry)| {
323                        (
324                            name,
325                            FieldDef {
326                                label: entry.label,
327                                field_name: entry.field_name,
328                                format: entry.format,
329                                interval: entry.interval,
330                                historical: entry.historical,
331                            },
332                        )
333                    })
334                    .collect();
335                (asset, mapped)
336            })
337            .collect();
338
339        Self {
340            defaults: raw.defaults,
341            presets: raw.presets,
342            catalog,
343            markets: raw
344                .markets
345                .into_iter()
346                .map(|m| Market {
347                    const_name: m.const_name,
348                    value: m.value,
349                })
350                .collect(),
351            types: raw
352                .types
353                .into_iter()
354                .map(|t| TypeFilter {
355                    const_name: t.const_name,
356                    value: t.value,
357                })
358                .collect(),
359            symbol_types: raw
360                .symbol_types
361                .into_iter()
362                .map(|st| SymbolType {
363                    const_name: st.const_name,
364                    values: st.value,
365                })
366                .collect(),
367            index_symbols: raw
368                .index_symbols
369                .into_iter()
370                .map(|idx| IndexSymbolDef {
371                    const_name: idx.const_name,
372                    value: idx.value,
373                    label: idx.label,
374                })
375                .collect(),
376            sectors: map_named(raw.sectors),
377            countries: map_named(raw.countries),
378            industries: map_named(raw.industries),
379            exchanges: map_named(raw.exchanges),
380        }
381    }
382
383    fn field(&self, asset: Asset, const_name: &str) -> Option<FieldDef> {
384        self.catalog.get(asset.as_str())?.get(const_name).cloned()
385    }
386
387    fn defaults_for(&self, asset: Asset) -> Vec<FieldDef> {
388        self.defaults
389            .get(asset.as_str())
390            .map(|names| {
391                names
392                    .iter()
393                    .filter_map(|name| self.field(asset, name))
394                    .collect()
395            })
396            .unwrap_or_default()
397    }
398
399    fn resolve_preset_ref(&self, reference: &str) -> Option<FieldDef> {
400        let (asset_str, const_name) = reference.split_once('.')?;
401        let asset = Asset::parse(asset_str)?;
402        self.field(asset, const_name)
403    }
404
405    fn preset(&self, name: &str) -> Result<Vec<FieldDef>> {
406        let refs = self
407            .presets
408            .get(name)
409            .ok_or_else(|| TvscreenerError::InvalidRequest(format!("unknown preset: {name}")))?;
410        let fields: Vec<FieldDef> = refs
411            .iter()
412            .filter_map(|reference| self.resolve_preset_ref(reference))
413            .collect();
414        if fields.is_empty() {
415            return Err(TvscreenerError::InvalidRequest(format!(
416                "preset `{name}` resolved to no fields"
417            )));
418        }
419        Ok(fields)
420    }
421
422    fn search(&self, asset: Asset, query: &str) -> Vec<FieldDef> {
423        let query = query.to_ascii_lowercase();
424        self.catalog
425            .get(asset.as_str())
426            .map(|entries| {
427                entries
428                    .iter()
429                    .filter(|(name, field)| {
430                        name.to_ascii_lowercase().contains(&query)
431                            || field.label.to_ascii_lowercase().contains(&query)
432                            || field.field_name.to_ascii_lowercase().contains(&query)
433                    })
434                    .map(|(_, field)| field.clone())
435                    .collect()
436            })
437            .unwrap_or_default()
438    }
439}
440
441static CATALOG: OnceLock<FieldsCatalog> = OnceLock::new();
442
443fn catalog() -> &'static FieldsCatalog {
444    CATALOG.get_or_init(FieldsCatalog::load)
445}
446
447/// Looks up one catalog field by asset and constant name.
448#[must_use]
449pub(crate) fn field(asset: Asset, const_name: &str) -> Option<FieldDef> {
450    catalog().field(asset, const_name)
451}
452
453/// Resolves a field by constant name, technical name, or label (case-insensitive).
454///
455/// Prefer exact constant-name match (e.g. `"PRICE"`), then technical `field_name`,
456/// then label. Returns `(const_name, FieldDef)` when found.
457#[must_use]
458pub fn resolve_field(asset: Asset, name: &str) -> Option<(String, FieldDef)> {
459    let trimmed = name.trim();
460    if trimmed.is_empty() {
461        return None;
462    }
463    let upper = trimmed.to_ascii_uppercase().replace(' ', "_");
464    if let Some(def) = catalog().field(asset, &upper) {
465        return Some((upper, def));
466    }
467    let entries = catalog().catalog.get(asset.as_str())?;
468    let lower = trimmed.to_ascii_lowercase();
469    entries.iter().find_map(|(const_name, def)| {
470        if def.field_name.eq_ignore_ascii_case(trimmed)
471            || def.label.to_ascii_lowercase() == lower
472            || const_name.eq_ignore_ascii_case(&upper)
473        {
474            Some((const_name.clone(), def.clone()))
475        } else {
476            None
477        }
478    })
479}
480
481/// Returns a catalog field or an error when it is missing.
482///
483/// # Errors
484///
485/// Returns [`TvscreenerError::InvalidRequest`] when the constant name is absent for the asset.
486pub fn require_field(asset: Asset, const_name: &str) -> Result<FieldDef> {
487    field(asset, const_name).ok_or_else(|| {
488        TvscreenerError::InvalidRequest(format!(
489            "unknown field `{const_name}` for asset `{}`",
490            asset.as_str()
491        ))
492    })
493}
494
495/// Returns preset field lists by name
496///
497/// # Errors
498///
499/// Returns [`TvscreenerError::InvalidRequest`] when the preset is unknown or resolves to no fields.
500pub fn get_preset(name: &str) -> Result<Vec<FieldDef>> {
501    catalog().preset(name)
502}
503
504/// Lists known preset names sorted alphabetically
505#[must_use]
506pub fn list_presets() -> Vec<String> {
507    let mut names: Vec<String> = catalog().presets.keys().cloned().collect();
508    names.sort();
509    names
510}
511
512/// Searches catalog fields for an asset by label, constant name, or technical name.
513#[must_use]
514pub fn search_fields(asset: Asset, query: &str) -> Vec<FieldDef> {
515    catalog().search(asset, query)
516}
517
518/// Returns every field definition for an asset (full catalog, not only defaults).
519#[must_use]
520pub fn list_fields(asset: Asset) -> Vec<FieldDef> {
521    catalog()
522        .catalog
523        .get(asset.as_str())
524        .map(|entries| entries.values().cloned().collect())
525        .unwrap_or_default()
526}
527
528/// Returns how many fields are registered for an asset in the embedded catalog.
529#[must_use]
530pub fn catalog_len(asset: Asset) -> usize {
531    catalog()
532        .catalog
533        .get(asset.as_str())
534        .map_or(0, HashMap::len)
535}
536
537/// Returns all index symbol definitions from the catalog.
538#[must_use]
539pub fn all_index_symbols() -> &'static [IndexSymbolDef] {
540    &catalog().index_symbols
541}
542
543/// Looks up an index catalog symbol by constant name (e.g. `"SP500"` → `"SP;SPX"`).
544#[must_use]
545pub fn index_symbol_value(const_name: &str) -> Option<&'static str> {
546    catalog()
547        .index_symbols
548        .iter()
549        .find(|idx| idx.const_name == const_name)
550        .map(|idx| idx.value.as_str())
551}
552
553/// Formats a catalog index symbol for the scan `symbols.symbolset` array.
554///
555/// Index symbolset wire value (`SYML:` + catalog symbol).
556#[must_use]
557pub fn to_symbolset_wire(symbol: &str) -> String {
558    if symbol.starts_with("SYML:") {
559        symbol.to_string()
560    } else {
561        format!("SYML:{symbol}")
562    }
563}
564
565/// Returns all configured markets from the catalog.
566#[must_use]
567pub fn all_markets() -> &'static [Market] {
568    &catalog().markets
569}
570
571/// Finds a market by catalog constant name.
572#[must_use]
573pub fn market(const_name: &str) -> Option<Market> {
574    catalog()
575        .markets
576        .iter()
577        .find(|m| m.const_name == const_name)
578        .cloned()
579}
580
581/// Returns all sector catalog entries.
582#[must_use]
583pub fn all_sectors() -> &'static [NamedValue] {
584    &catalog().sectors
585}
586
587/// Sector wire value by constant name (e.g. `"TECHNOLOGY_SERVICES"`).
588#[must_use]
589pub fn sector_value(const_name: &str) -> Option<&'static str> {
590    named_value_str(&catalog().sectors, const_name)
591}
592
593/// Resolves a sector token (const name or wire value) to the catalog wire string.
594#[must_use]
595pub fn resolve_sector(token: &str) -> Option<&'static str> {
596    resolve_named_wire(&catalog().sectors, token)
597}
598
599/// Returns all country catalog entries.
600#[must_use]
601pub fn all_countries() -> &'static [NamedValue] {
602    &catalog().countries
603}
604
605/// Country wire value by constant name.
606#[must_use]
607pub fn country_value(const_name: &str) -> Option<&'static str> {
608    named_value_str(&catalog().countries, const_name)
609}
610
611/// Resolves a country token (const name or wire value).
612#[must_use]
613pub fn resolve_country(token: &str) -> Option<&'static str> {
614    resolve_named_wire(&catalog().countries, token)
615}
616
617/// Returns all industry catalog entries.
618#[must_use]
619pub fn all_industries() -> &'static [NamedValue] {
620    &catalog().industries
621}
622
623/// Industry wire value by constant name.
624#[must_use]
625pub fn industry_value(const_name: &str) -> Option<&'static str> {
626    named_value_str(&catalog().industries, const_name)
627}
628
629/// Resolves an industry token (const name or wire value).
630#[must_use]
631pub fn resolve_industry(token: &str) -> Option<&'static str> {
632    resolve_named_wire(&catalog().industries, token)
633}
634
635/// Returns all exchange catalog entries.
636#[must_use]
637pub fn all_exchanges() -> &'static [NamedValue] {
638    &catalog().exchanges
639}
640
641/// Exchange wire value by constant name.
642#[must_use]
643pub fn exchange_value(const_name: &str) -> Option<&'static str> {
644    named_value_str(&catalog().exchanges, const_name)
645}
646
647/// Resolves an exchange token (const name or wire value).
648#[must_use]
649pub fn resolve_exchange(token: &str) -> Option<&'static str> {
650    resolve_named_wire(&catalog().exchanges, token)
651}
652
653/// Returns recommendation rating bands (same order as [`rating::ALL`]).
654#[must_use]
655pub const fn all_ratings() -> &'static [RatingBand] {
656    rating::ALL
657}
658
659/// Looks up a rating band by constant name.
660#[must_use]
661pub fn rating(const_name: &str) -> Option<RatingBand> {
662    rating::ALL
663        .iter()
664        .copied()
665        .find(|r| r.const_name == const_name)
666}
667
668/// Finds the rating band containing `value`, or [`rating::UNKNOWN`].
669#[must_use]
670pub fn rating_find(value: f64) -> RatingBand {
671    for band in rating::ALL {
672        if band.const_name == "UNKNOWN" {
673            continue;
674        }
675        if band.contains(value) {
676            return *band;
677        }
678    }
679    rating::UNKNOWN
680}
681
682/// Finds a stock `type` filter by catalog constant name.
683#[must_use]
684pub fn type_filter(const_name: &str) -> Option<TypeFilter> {
685    catalog()
686        .types
687        .iter()
688        .find(|t| t.const_name == const_name)
689        .cloned()
690}
691
692/// Finds a stock symbol subtype by catalog constant name.
693#[must_use]
694pub fn symbol_type(const_name: &str) -> Option<SymbolType> {
695    catalog()
696        .symbol_types
697        .iter()
698        .find(|st| st.const_name == const_name)
699        .cloned()
700}
701
702/// Default crypto columns (`DEFAULT_CRYPTO_FIELDS`).
703#[must_use]
704pub fn default_crypto_fields() -> Vec<FieldDef> {
705    catalog().defaults_for(Asset::Crypto)
706}
707
708/// Default stock columns (`DEFAULT_STOCK_FIELDS`).
709#[must_use]
710pub fn default_stock_fields() -> Vec<FieldDef> {
711    catalog().defaults_for(Asset::Stock)
712}
713
714/// Default forex columns (`DEFAULT_FOREX_FIELDS`).
715#[must_use]
716pub fn default_forex_fields() -> Vec<FieldDef> {
717    catalog().defaults_for(Asset::Forex)
718}
719
720/// Default bond columns; falls back to `bond_basic` preset when the catalog default list is empty.
721#[must_use]
722pub fn default_bond_fields() -> Vec<FieldDef> {
723    let fields = catalog().defaults_for(Asset::Bond);
724    if fields.is_empty() {
725        get_preset("bond_basic").unwrap_or_default()
726    } else {
727        fields
728    }
729}
730
731/// Default futures columns (`DEFAULT_FUTURES_FIELDS`).
732#[must_use]
733pub fn default_futures_fields() -> Vec<FieldDef> {
734    catalog().defaults_for(Asset::Futures)
735}
736
737/// Default coin columns (`DEFAULT_COIN_FIELDS`).
738#[must_use]
739pub fn default_coin_fields() -> Vec<FieldDef> {
740    catalog().defaults_for(Asset::Coin)
741}
742
743/// Default columns for `asset` (same as the typed screener constructors).
744#[must_use]
745pub fn default_fields(asset: Asset) -> Vec<FieldDef> {
746    match asset {
747        Asset::Stock => default_stock_fields(),
748        Asset::Crypto => default_crypto_fields(),
749        Asset::Forex => default_forex_fields(),
750        Asset::Bond => default_bond_fields(),
751        Asset::Futures => default_futures_fields(),
752        Asset::Coin => default_coin_fields(),
753    }
754}
755
756/// Crypto sort field: 24h volume in USD.
757///
758/// # Panics
759///
760/// Panics if `VOLUME_24H_IN_USD` is missing from the embedded catalog.
761#[must_use]
762pub fn crypto_volume_24h_in_usd() -> FieldDef {
763    require_field(Asset::Crypto, "VOLUME_24H_IN_USD").expect("VOLUME_24H_IN_USD")
764}
765
766/// Stock sort field: market capitalization.
767///
768/// # Panics
769///
770/// Panics if `MARKET_CAPITALIZATION` is missing from the embedded catalog.
771#[must_use]
772pub fn stock_market_capitalization() -> FieldDef {
773    require_field(Asset::Stock, "MARKET_CAPITALIZATION").expect("MARKET_CAPITALIZATION")
774}
775
776/// Forex sort field: name.
777///
778/// # Panics
779///
780/// Panics if `NAME` is missing from the embedded catalog.
781#[must_use]
782pub fn forex_name() -> FieldDef {
783    require_field(Asset::Forex, "NAME").expect("NAME")
784}
785
786/// Bond sort field: volume.
787///
788/// # Panics
789///
790/// Panics if `VOLUME` is missing from the embedded catalog.
791#[must_use]
792pub fn bond_volume() -> FieldDef {
793    require_field(Asset::Bond, "VOLUME").expect("VOLUME")
794}
795
796/// Futures sort field: volume.
797///
798/// # Panics
799///
800/// Panics if `VOLUME` is missing from the embedded catalog.
801#[must_use]
802pub fn futures_volume() -> FieldDef {
803    require_field(Asset::Futures, "VOLUME").expect("VOLUME")
804}
805
806/// Coin sort field: market cap.
807///
808/// # Panics
809///
810/// Panics if `MARKET_CAP` is missing from the embedded catalog.
811#[must_use]
812pub fn coin_market_cap() -> FieldDef {
813    require_field(Asset::Coin, "MARKET_CAP").expect("MARKET_CAP")
814}
815
816/// Crypto RSI(14) field used in technical presets and filters.
817///
818/// # Panics
819///
820/// Panics if `RELATIVE_STRENGTH_INDEX_14` is missing from the embedded catalog.
821#[must_use]
822pub fn crypto_rsi_14() -> FieldDef {
823    require_field(Asset::Crypto, "RELATIVE_STRENGTH_INDEX_14").expect("RELATIVE_STRENGTH_INDEX_14")
824}
825
826/// Crypto price (`close`) field.
827///
828/// # Panics
829///
830/// Panics if `PRICE` is missing from the embedded catalog.
831#[must_use]
832pub fn crypto_price() -> FieldDef {
833    require_field(Asset::Crypto, "PRICE").expect("PRICE")
834}
835
836/// Common index catalog symbols for [`crate::core::Screener::set_index`].
837///
838/// Generated from `data/fields.json` - run `tvscreener regen-fields` to refresh.
839pub mod index_symbol {
840    include!("generated/index_symbol.rs");
841}
842
843/// Stock sector wire values for `SECTOR` filters.
844///
845/// Generated from `data/fields.json` - run `tvscreener regen-fields` to refresh.
846pub mod sector {
847    include!("generated/sector.rs");
848}
849
850/// Country wire values for country filters.
851///
852/// Generated from `data/fields.json` - run `tvscreener regen-fields` to refresh.
853pub mod country {
854    include!("generated/country.rs");
855}
856
857/// Industry wire values for industry filters.
858///
859/// Generated from `data/fields.json` - run `tvscreener regen-fields` to refresh.
860pub mod industry {
861    include!("generated/industry.rs");
862}
863
864/// Exchange wire values for exchange filters.
865///
866/// Generated from `data/fields.json` - run `tvscreener regen-fields` to refresh.
867pub mod exchange {
868    include!("generated/exchange.rs");
869}
870
871/// Recommendation rating bands (`STRONG_BUY` … `UNKNOWN`).
872///
873/// Generated from `data/fields.json` - run `tvscreener regen-fields` to refresh.
874pub mod rating {
875    use super::RatingBand;
876    include!("generated/rating.rs");
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    #[test]
884    fn resolve_field_const_technical_and_label() {
885        let (name, def) = resolve_field(Asset::Crypto, "PRICE").expect("const");
886        assert_eq!(name, "PRICE");
887        assert_eq!(def.field_name, "close");
888
889        let (name, def) = resolve_field(Asset::Crypto, "close").expect("technical");
890        assert_eq!(name, "PRICE");
891        assert_eq!(def.field_name, "close");
892
893        let by_label = resolve_field(Asset::Crypto, &def.label);
894        assert!(by_label.is_some());
895        assert!(resolve_field(Asset::Crypto, "not_a_real_field_xyz").is_none());
896    }
897
898    #[test]
899    fn require_field_unknown_is_invalid() {
900        let err = require_field(Asset::Crypto, "NOPE").expect_err("missing");
901        assert!(matches!(err, crate::TvscreenerError::InvalidRequest(_)));
902        assert!(err.to_string().contains("crypto"));
903    }
904
905    #[test]
906    fn asset_parse_round_trip() {
907        assert_eq!(Asset::parse("crypto"), Some(Asset::Crypto));
908        assert_eq!(Asset::parse("stock"), Some(Asset::Stock));
909        assert_eq!(Asset::parse("forex"), Some(Asset::Forex));
910        assert_eq!(Asset::parse("bond"), Some(Asset::Bond));
911        assert_eq!(Asset::parse("futures"), Some(Asset::Futures));
912        assert_eq!(Asset::parse("coin"), Some(Asset::Coin));
913        assert_eq!(Asset::parse("nope"), None);
914        assert_eq!(Asset::Crypto.as_str(), "crypto");
915    }
916
917    #[test]
918    fn default_crypto_fields_non_empty() {
919        let fields = default_crypto_fields();
920        assert!(fields.len() > 50);
921        assert!(fields.iter().any(|f| f.field_name == "name"));
922    }
923
924    #[test]
925    fn get_preset_crypto_price() {
926        let fields = get_preset("crypto_price").expect("preset");
927        assert!(fields.iter().any(|f| f.label == "Name"));
928        assert_eq!(fields.len(), 7);
929    }
930
931    #[test]
932    fn list_presets_includes_crypto_price() {
933        let names = list_presets();
934        assert!(names.contains(&"crypto_price".to_string()));
935    }
936
937    #[test]
938    fn search_fields_finds_volume() {
939        let hits = search_fields(Asset::Crypto, "volume");
940        assert!(hits
941            .iter()
942            .any(|f| f.field_name.contains("vol") || f.label.contains("Volume")));
943    }
944
945    #[test]
946    fn full_catalog_is_much_larger_than_defaults() {
947        assert!(catalog_len(Asset::Crypto) > 3000);
948        assert!(catalog_len(Asset::Stock) > 3000);
949        assert_eq!(list_fields(Asset::Crypto).len(), catalog_len(Asset::Crypto));
950        assert!(default_crypto_fields().len() < catalog_len(Asset::Crypto));
951    }
952
953    #[test]
954    fn index_symbol_sp500_wire_and_catalog() {
955        assert_eq!(index_symbol::SP500, "SP;SPX");
956        assert_eq!(index_symbol_value("SP500"), Some("SP;SPX"));
957        assert_eq!(to_symbolset_wire("SP;SPX"), "SYML:SP;SPX");
958        assert_eq!(to_symbolset_wire("SYML:SP;SPX"), "SYML:SP;SPX");
959        assert_eq!(
960            all_index_symbols().len(),
961            index_symbol::ALL_CONST_NAMES.len()
962        );
963        assert!(all_index_symbols().len() >= 50);
964    }
965
966    #[test]
967    fn market_america_value() {
968        assert_eq!(Market::america().value, "america");
969    }
970
971    #[test]
972    fn sector_country_industry_exchange_catalog() {
973        assert_eq!(sector::TECHNOLOGY_SERVICES, "Technology Services");
974        assert_eq!(
975            sector_value("TECHNOLOGY_SERVICES"),
976            Some("Technology Services")
977        );
978        assert_eq!(
979            resolve_sector("Technology Services"),
980            Some("Technology Services")
981        );
982        assert_eq!(all_sectors().len(), sector::ALL_CONST_NAMES.len());
983        assert_eq!(country::UNITED_STATES, "United States");
984        assert_eq!(resolve_country("UNITED_STATES"), Some("United States"));
985        assert_eq!(resolve_country("United States"), Some("United States"));
986        assert_eq!(all_countries().len(), country::ALL_CONST_NAMES.len());
987        assert_eq!(industry::SEMICONDUCTORS, "Semiconductors");
988        assert_eq!(resolve_industry("SEMICONDUCTORS"), Some("Semiconductors"));
989        assert_eq!(all_industries().len(), industry::ALL_CONST_NAMES.len());
990        assert_eq!(exchange::NASDAQ, "NASDAQ");
991        assert_eq!(resolve_exchange("NASDAQ"), Some("NASDAQ"));
992        assert_eq!(all_exchanges().len(), exchange::ALL_CONST_NAMES.len());
993        assert_eq!(all_ratings().len(), rating::ALL_CONST_NAMES.len());
994    }
995
996    #[test]
997    fn rating_find_bands() {
998        assert_eq!(rating_find(0.7).const_name, "STRONG_BUY");
999        assert_eq!(rating_find(0.0).const_name, "NEUTRAL");
1000        assert_eq!(rating_find(-0.9).const_name, "STRONG_SELL");
1001        assert_eq!(rating_find(f64::NAN).const_name, "UNKNOWN");
1002        assert_eq!(rating("BUY").map(|r| r.label), Some("Buy"));
1003        assert_eq!(rating::BUY.range(), Some([0.1, 0.5]));
1004    }
1005
1006    #[test]
1007    fn bond_defaults_or_preset_fallback() {
1008        let fields = default_bond_fields();
1009        assert_ne!(fields.len(), 0);
1010    }
1011}