Skip to main content

tvdata_rs/scanner/
registry.rs

1use std::collections::HashMap;
2
3use once_cell::sync::Lazy;
4use serde::Deserialize;
5
6use crate::scanner::field::Column;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum ScreenerKind {
11    Stock,
12    Crypto,
13    Forex,
14    Bond,
15    Futures,
16    Coin,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
20pub struct FieldDescriptor {
21    pub name: String,
22    pub label: String,
23    pub field_name: String,
24    pub format: Option<String>,
25    pub interval: bool,
26    pub historical: bool,
27}
28
29impl FieldDescriptor {
30    pub fn column(&self) -> Column {
31        Column::new(self.field_name.clone())
32    }
33
34    pub fn recommendation_column(&self) -> Option<Column> {
35        (self.format.as_deref() == Some("recommendation")).then(|| self.column().recommendation())
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
40pub struct MarketDescriptor {
41    pub name: String,
42    pub value: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
46pub struct SymbolTypeDescriptor {
47    pub name: String,
48    pub value: Vec<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
52pub struct IndexSymbolDescriptor {
53    pub name: String,
54    pub symbol: String,
55    pub symbolset_value: String,
56    pub label: String,
57}
58
59#[derive(Debug, Clone, Deserialize)]
60struct RegistryEnvelope {
61    screeners: HashMap<ScreenerKind, Vec<FieldDescriptor>>,
62    markets: Vec<MarketDescriptor>,
63    symbol_types: Vec<SymbolTypeDescriptor>,
64    index_symbols: Vec<IndexSymbolDescriptor>,
65}
66
67#[derive(Debug, Clone)]
68pub struct FieldRegistry {
69    screeners: HashMap<ScreenerKind, Vec<FieldDescriptor>>,
70    markets: Vec<MarketDescriptor>,
71    symbol_types: Vec<SymbolTypeDescriptor>,
72    index_symbols: Vec<IndexSymbolDescriptor>,
73}
74
75impl FieldRegistry {
76    pub fn from_embedded() -> Self {
77        let mut envelope: RegistryEnvelope =
78            serde_json::from_str(include_str!("../../assets/field_registry.json"))
79                .expect("embedded field registry must be valid JSON");
80        normalize_embedded_field_names(&mut envelope.screeners);
81        Self {
82            screeners: envelope.screeners,
83            markets: envelope.markets,
84            symbol_types: envelope.symbol_types,
85            index_symbols: envelope.index_symbols,
86        }
87    }
88
89    pub fn fields(&self, screener: ScreenerKind) -> &[FieldDescriptor] {
90        self.screeners
91            .get(&screener)
92            .map(Vec::as_slice)
93            .unwrap_or(&[])
94    }
95
96    pub fn search(&self, screener: ScreenerKind, query: &str) -> Vec<&FieldDescriptor> {
97        let query = query.to_ascii_lowercase();
98        self.fields(screener)
99            .iter()
100            .filter(|field| {
101                field.name.to_ascii_lowercase().contains(&query)
102                    || field.label.to_ascii_lowercase().contains(&query)
103                    || field.field_name.to_ascii_lowercase().contains(&query)
104            })
105            .collect()
106    }
107
108    pub fn find_by_api_name(
109        &self,
110        screener: ScreenerKind,
111        api_name: &str,
112    ) -> Option<&FieldDescriptor> {
113        self.fields(screener)
114            .iter()
115            .find(|field| field.field_name == api_name)
116    }
117
118    pub fn markets(&self) -> &[MarketDescriptor] {
119        &self.markets
120    }
121
122    pub fn symbol_types(&self) -> &[SymbolTypeDescriptor] {
123        &self.symbol_types
124    }
125
126    pub fn index_symbols(&self) -> &[IndexSymbolDescriptor] {
127        &self.index_symbols
128    }
129}
130
131fn normalize_embedded_field_names(screeners: &mut HashMap<ScreenerKind, Vec<FieldDescriptor>>) {
132    for fields in screeners.values_mut() {
133        for field in fields {
134            field.field_name = normalize_field_name(&field.field_name);
135        }
136    }
137}
138
139fn normalize_field_name(field_name: &str) -> String {
140    if let Some((prefix, suffix)) = field_name.split_once('.')
141        && (prefix == "change" || prefix == "change_abs" || prefix == "relative_volume_intraday")
142        && (suffix.chars().all(|c| c.is_ascii_digit()) || matches!(suffix, "1W" | "1M"))
143    {
144        return format!("{prefix}|{suffix}");
145    }
146
147    field_name.to_owned()
148}
149
150pub fn embedded_registry() -> &'static FieldRegistry {
151    static REGISTRY: Lazy<FieldRegistry> = Lazy::new(FieldRegistry::from_embedded);
152    &REGISTRY
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn embedded_registry_matches_reference_counts() {
161        let registry = embedded_registry();
162        assert_eq!(registry.fields(ScreenerKind::Stock).len(), 3526);
163        assert_eq!(registry.fields(ScreenerKind::Crypto).len(), 3108);
164        assert_eq!(registry.fields(ScreenerKind::Forex).len(), 2965);
165        assert_eq!(registry.markets().len(), 66);
166        assert_eq!(registry.index_symbols().len(), 50);
167    }
168
169    #[test]
170    fn registry_searches_by_name_label_and_api_name() {
171        let registry = embedded_registry();
172        let matches = registry.search(ScreenerKind::Stock, "dividend");
173        assert!(
174            matches
175                .iter()
176                .any(|field| field.field_name == "dividend_yield_recent")
177        );
178        assert!(
179            registry
180                .find_by_api_name(ScreenerKind::Stock, "market_cap_basic")
181                .is_some()
182        );
183    }
184
185    #[test]
186    fn normalizes_timed_fields_to_api_format() {
187        let registry = embedded_registry();
188        assert!(
189            registry
190                .find_by_api_name(ScreenerKind::Stock, "change|1W")
191                .is_some()
192        );
193    }
194
195    #[test]
196    fn builds_recommendation_companion_columns() {
197        let registry = embedded_registry();
198        let field = registry
199            .find_by_api_name(ScreenerKind::Stock, "BBPower")
200            .expect("BBPower should exist");
201        let recommendation = field
202            .recommendation_column()
203            .expect("BBPower should expose recommendation column");
204        assert_eq!(recommendation.as_str(), "Rec.BBPower");
205    }
206}