tvdata_rs/scanner/
registry.rs1use 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 ®ISTRY
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}