Skip to main content

dukascopy_fx/core/
catalog.rs

1//! Instrument catalog and universe loading utilities.
2
3use crate::error::DukascopyError;
4use crate::models::CurrencyPair;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::path::Path;
9
10/// Asset class for an instrument.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum AssetClass {
14    Fx,
15    Metal,
16    Equity,
17    Index,
18    Commodity,
19    Crypto,
20    Other,
21}
22
23/// Instrument metadata used by the fetcher.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct InstrumentDefinition {
26    /// Full symbol in Dukascopy format, e.g. `EURUSD`.
27    pub symbol: String,
28    /// Base instrument code.
29    pub base: String,
30    /// Quote instrument code.
31    pub quote: String,
32    /// Instrument class.
33    pub asset_class: AssetClass,
34    /// Divisor used to decode raw prices.
35    pub price_divisor: f64,
36    /// Number of decimal places for formatting.
37    pub decimal_places: u32,
38    /// Whether instrument is active in the universe.
39    #[serde(default = "default_true")]
40    pub active: bool,
41}
42
43fn default_true() -> bool {
44    true
45}
46
47impl InstrumentDefinition {
48    /// Returns pair representation for this instrument.
49    pub fn pair(&self) -> CurrencyPair {
50        CurrencyPair::new(&self.base, &self.quote)
51    }
52}
53
54/// Collection of instruments used by the fetcher.
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct InstrumentCatalog {
57    /// Catalog entries.
58    pub instruments: Vec<InstrumentDefinition>,
59    /// Optional code aliases, e.g. `AAPL -> AAPLUS`.
60    #[serde(default)]
61    pub code_aliases: HashMap<String, String>,
62}
63
64impl InstrumentCatalog {
65    /// Load catalog from a JSON file.
66    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, DukascopyError> {
67        let content = fs::read_to_string(path.as_ref()).map_err(|err| {
68            DukascopyError::Unknown(format!(
69                "Failed to read instrument universe file '{}': {}",
70                path.as_ref().display(),
71                err
72            ))
73        })?;
74
75        Self::from_json_str(&content)
76    }
77
78    /// Parse catalog from JSON content.
79    pub fn from_json_str(content: &str) -> Result<Self, DukascopyError> {
80        let catalog: Self = serde_json::from_str(content).map_err(|err| {
81            DukascopyError::InvalidRequest(format!("Invalid instrument universe JSON: {}", err))
82        })?;
83        catalog.validate()?;
84        Ok(catalog)
85    }
86
87    /// Returns all active instruments.
88    pub fn active_instruments(&self) -> Vec<&InstrumentDefinition> {
89        self.instruments.iter().filter(|i| i.active).collect()
90    }
91
92    /// Finds instrument by symbol (case-insensitive).
93    pub fn find(&self, symbol: &str) -> Option<&InstrumentDefinition> {
94        let symbol = symbol.trim().to_ascii_uppercase();
95        self.instruments.iter().find(|i| i.symbol == symbol)
96    }
97
98    /// Returns active instruments matching provided symbols.
99    pub fn select_active(
100        &self,
101        symbols: &[String],
102    ) -> Result<Vec<&InstrumentDefinition>, DukascopyError> {
103        if symbols.is_empty() {
104            return Ok(self.active_instruments());
105        }
106
107        let mut selected = Vec::with_capacity(symbols.len());
108        for symbol in symbols {
109            let instrument = self.find(symbol).ok_or_else(|| {
110                DukascopyError::InvalidRequest(format!(
111                    "Instrument '{}' not found in catalog",
112                    symbol
113                ))
114            })?;
115            if !instrument.active {
116                return Err(DukascopyError::InvalidRequest(format!(
117                    "Instrument '{}' is marked as inactive",
118                    symbol
119                )));
120            }
121            selected.push(instrument);
122        }
123        Ok(selected)
124    }
125
126    /// Resolves code alias to canonical code.
127    pub fn resolve_code_alias(&self, code: &str) -> String {
128        let aliases = self.normalized_code_aliases();
129        resolve_alias_chain(&aliases, code.trim().to_ascii_uppercase())
130    }
131
132    /// Returns normalized alias map.
133    pub fn normalized_code_aliases(&self) -> HashMap<String, String> {
134        let aliases: HashMap<String, String> = self
135            .code_aliases
136            .iter()
137            .map(|(alias, canonical)| {
138                (
139                    alias.trim().to_ascii_uppercase(),
140                    canonical.trim().to_ascii_uppercase(),
141                )
142            })
143            .collect();
144
145        aliases
146            .keys()
147            .map(|alias| (alias.clone(), resolve_alias_chain(&aliases, alias.clone())))
148            .collect()
149    }
150
151    fn validate(&self) -> Result<(), DukascopyError> {
152        if self.instruments.is_empty() {
153            return Err(DukascopyError::InvalidRequest(
154                "Instrument catalog cannot be empty".to_string(),
155            ));
156        }
157
158        for instrument in &self.instruments {
159            if instrument.symbol.len() < 6 {
160                return Err(DukascopyError::InvalidRequest(format!(
161                    "Invalid symbol '{}' in catalog",
162                    instrument.symbol
163                )));
164            }
165
166            if !is_valid_instrument_code(&instrument.base)
167                || !is_valid_instrument_code(&instrument.quote)
168            {
169                return Err(DukascopyError::InvalidRequest(format!(
170                    "Invalid base/quote for symbol '{}'",
171                    instrument.symbol
172                )));
173            }
174
175            let expected_symbol = format!(
176                "{}{}",
177                instrument.base.to_ascii_uppercase(),
178                instrument.quote.to_ascii_uppercase()
179            );
180            if instrument.symbol.to_ascii_uppercase() != expected_symbol {
181                return Err(DukascopyError::InvalidRequest(format!(
182                    "Invalid symbol '{}' in catalog: expected '{}'",
183                    instrument.symbol, expected_symbol
184                )));
185            }
186
187            if instrument.price_divisor <= 0.0 {
188                return Err(DukascopyError::InvalidRequest(format!(
189                    "Invalid price_divisor for symbol '{}'",
190                    instrument.symbol
191                )));
192            }
193        }
194
195        let known_codes: HashSet<String> = self
196            .instruments
197            .iter()
198            .flat_map(|instrument| {
199                [
200                    instrument.base.trim().to_ascii_uppercase(),
201                    instrument.quote.trim().to_ascii_uppercase(),
202                ]
203            })
204            .collect();
205
206        for (alias, canonical) in self.normalized_code_aliases() {
207            if !is_valid_instrument_code(&alias) || !is_valid_instrument_code(&canonical) {
208                return Err(DukascopyError::InvalidRequest(format!(
209                    "Invalid code alias mapping '{} -> {}'",
210                    alias, canonical
211                )));
212            }
213            if !known_codes.contains(&canonical) {
214                return Err(DukascopyError::InvalidRequest(format!(
215                    "Alias canonical '{}' is not present in instrument catalog",
216                    canonical
217                )));
218            }
219        }
220
221        Ok(())
222    }
223}
224
225fn is_valid_instrument_code(code: &str) -> bool {
226    let len = code.len();
227    (2..=12).contains(&len) && code.chars().all(|ch| ch.is_ascii_alphanumeric())
228}
229
230fn resolve_alias_chain(aliases: &HashMap<String, String>, initial: String) -> String {
231    let mut current = initial;
232    let mut visited = HashSet::new();
233
234    while let Some(next) = aliases.get(&current) {
235        if !visited.insert(current.clone()) {
236            break;
237        }
238
239        if next == &current {
240            break;
241        }
242
243        current = next.clone();
244    }
245
246    current
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_parse_catalog() {
255        let json = r#"
256        {
257          "instruments": [
258            {
259              "symbol": "EURUSD",
260              "base": "EUR",
261              "quote": "USD",
262              "asset_class": "fx",
263              "price_divisor": 100000.0,
264              "decimal_places": 5,
265              "active": true
266            }
267          ]
268        }
269        "#;
270
271        let catalog = InstrumentCatalog::from_json_str(json).unwrap();
272        assert_eq!(catalog.instruments.len(), 1);
273        assert_eq!(catalog.active_instruments().len(), 1);
274    }
275
276    #[test]
277    fn test_find_case_insensitive() {
278        let json = r#"
279        {
280          "instruments": [
281            {
282              "symbol": "USDJPY",
283              "base": "USD",
284              "quote": "JPY",
285              "asset_class": "fx",
286              "price_divisor": 1000.0,
287              "decimal_places": 3,
288              "active": true
289            }
290          ]
291        }
292        "#;
293
294        let catalog = InstrumentCatalog::from_json_str(json).unwrap();
295        assert!(catalog.find("usdjpy").is_some());
296    }
297
298    #[test]
299    fn test_catalog_allows_non_three_char_instrument_codes() {
300        let json = r#"
301        {
302          "instruments": [
303            {
304              "symbol": "DE40USD",
305              "base": "DE40",
306              "quote": "USD",
307              "asset_class": "index",
308              "price_divisor": 100.0,
309              "decimal_places": 2,
310              "active": true
311            }
312          ]
313        }
314        "#;
315
316        let catalog = InstrumentCatalog::from_json_str(json).unwrap();
317        assert_eq!(catalog.instruments.len(), 1);
318        assert_eq!(catalog.instruments[0].symbol, "DE40USD");
319    }
320
321    #[test]
322    fn test_catalog_code_aliases() {
323        let json = r#"
324        {
325          "instruments": [
326            {
327              "symbol": "AAPLUSUSD",
328              "base": "AAPLUS",
329              "quote": "USD",
330              "asset_class": "equity",
331              "price_divisor": 1000.0,
332              "decimal_places": 2,
333              "active": true
334            }
335          ],
336          "code_aliases": {
337            "AAPL": "AAPLUS"
338          }
339        }
340        "#;
341
342        let catalog = InstrumentCatalog::from_json_str(json).unwrap();
343        assert_eq!(catalog.resolve_code_alias("aapl"), "AAPLUS");
344        assert_eq!(catalog.resolve_code_alias("msft"), "MSFT");
345    }
346
347    #[test]
348    fn test_catalog_alias_chain_resolution() {
349        let json = r#"
350        {
351          "instruments": [
352            {
353              "symbol": "USA500IDXUSD",
354              "base": "USA500IDX",
355              "quote": "USD",
356              "asset_class": "index",
357              "price_divisor": 1000.0,
358              "decimal_places": 2,
359              "active": true
360            }
361          ],
362          "code_aliases": {
363            "SP500": "US500",
364            "US500": "USA500IDX"
365          }
366        }
367        "#;
368
369        let catalog = InstrumentCatalog::from_json_str(json).unwrap();
370        assert_eq!(catalog.resolve_code_alias("SP500"), "USA500IDX");
371    }
372
373    #[test]
374    fn test_catalog_alias_canonical_must_exist_in_catalog_codes() {
375        let json = r#"
376        {
377          "instruments": [
378            {
379              "symbol": "EURUSD",
380              "base": "EUR",
381              "quote": "USD",
382              "asset_class": "fx",
383              "price_divisor": 100000.0,
384              "decimal_places": 5,
385              "active": true
386            }
387          ],
388          "code_aliases": {
389            "SPOT": "MISSING"
390          }
391        }
392        "#;
393
394        let error = InstrumentCatalog::from_json_str(json).unwrap_err();
395        assert!(error
396            .to_string()
397            .contains("not present in instrument catalog"));
398    }
399}