1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct InstrumentDefinition {
26 pub symbol: String,
28 pub base: String,
30 pub quote: String,
32 pub asset_class: AssetClass,
34 pub price_divisor: f64,
36 pub decimal_places: u32,
38 #[serde(default = "default_true")]
40 pub active: bool,
41}
42
43fn default_true() -> bool {
44 true
45}
46
47impl InstrumentDefinition {
48 pub fn pair(&self) -> CurrencyPair {
50 CurrencyPair::new(&self.base, &self.quote)
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct InstrumentCatalog {
57 pub instruments: Vec<InstrumentDefinition>,
59 #[serde(default)]
61 pub code_aliases: HashMap<String, String>,
62}
63
64impl InstrumentCatalog {
65 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 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 pub fn active_instruments(&self) -> Vec<&InstrumentDefinition> {
89 self.instruments.iter().filter(|i| i.active).collect()
90 }
91
92 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 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 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 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(¤t) {
235 if !visited.insert(current.clone()) {
236 break;
237 }
238
239 if next == ¤t {
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}