1use std::collections::HashMap;
4
5pub const DIVISOR_5_DECIMALS: f64 = 100_000.0;
7
8pub const DIVISOR_3_DECIMALS: f64 = 1_000.0;
10
11pub const DIVISOR_2_DECIMALS: f64 = 100.0;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct InstrumentConfig {
17 pub price_divisor: f64,
19 pub decimal_places: u32,
21}
22
23impl InstrumentConfig {
24 #[inline]
25 pub const fn new(price_divisor: f64, decimal_places: u32) -> Self {
26 Self {
27 price_divisor,
28 decimal_places,
29 }
30 }
31
32 pub const STANDARD: Self = Self::new(DIVISOR_5_DECIMALS, 5);
34
35 pub const JPY: Self = Self::new(DIVISOR_3_DECIMALS, 3);
37
38 pub const METALS: Self = Self::new(DIVISOR_3_DECIMALS, 3);
40
41 pub const RUB: Self = Self::new(DIVISOR_3_DECIMALS, 3);
43
44 pub const INDEX: Self = Self::new(DIVISOR_3_DECIMALS, 2);
46}
47
48impl Default for InstrumentConfig {
49 fn default() -> Self {
50 Self::STANDARD
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CurrencyCategory {
57 Standard,
58 Jpy,
59 Rub,
60 Metal,
61 Unknown,
62}
63
64impl CurrencyCategory {
65 pub fn from_code(code: &str) -> Self {
67 let code_upper = normalize_code(code);
68 match code_upper.as_str() {
69 "JPY" => Self::Jpy,
70 "RUB" => Self::Rub,
71 "XAU" | "XAG" | "XPT" | "XPD" => Self::Metal,
72 "USD" | "EUR" | "GBP" | "AUD" | "NZD" | "CAD" | "CHF" | "SEK" | "NOK" | "DKK"
73 | "SGD" | "HKD" | "MXN" | "ZAR" | "TRY" | "PLN" | "CZK" | "HUF" | "CNH" | "CNY"
74 | "INR" | "THB" | "KRW" | "TWD" | "BRL" | "ILS" => Self::Standard,
75 _ => Self::Unknown,
76 }
77 }
78
79 pub const fn config(&self) -> InstrumentConfig {
80 match self {
81 Self::Jpy => InstrumentConfig::JPY,
82 Self::Rub => InstrumentConfig::RUB,
83 Self::Metal => InstrumentConfig::METALS,
84 Self::Standard | Self::Unknown => InstrumentConfig::STANDARD,
85 }
86 }
87}
88
89pub fn resolve_instrument_config(from: &str, to: &str) -> InstrumentConfig {
91 if looks_like_index_code(from)
92 || looks_like_index_code(to)
93 || looks_like_equity_code(from)
94 || looks_like_equity_code(to)
95 {
96 return InstrumentConfig::INDEX;
97 }
98
99 let from_cat = CurrencyCategory::from_code(from);
100 let to_cat = CurrencyCategory::from_code(to);
101
102 match (from_cat, to_cat) {
103 (CurrencyCategory::Metal, _) | (_, CurrencyCategory::Metal) => InstrumentConfig::METALS,
104 (CurrencyCategory::Jpy, _) | (_, CurrencyCategory::Jpy) => InstrumentConfig::JPY,
105 (CurrencyCategory::Rub, _) | (_, CurrencyCategory::Rub) => InstrumentConfig::RUB,
106 _ => InstrumentConfig::STANDARD,
107 }
108}
109
110#[inline]
111fn normalize_code(code: &str) -> String {
112 code.trim().to_ascii_uppercase()
113}
114
115#[inline]
116fn pair_key(from: &str, to: &str) -> String {
117 format!("{}/{}", normalize_code(from), normalize_code(to))
119}
120
121#[inline]
122fn looks_like_index_code(code: &str) -> bool {
123 let normalized = normalize_code(code);
124 normalized.contains("IDX") || normalized.chars().any(|ch| ch.is_ascii_digit())
125}
126
127#[inline]
128fn looks_like_equity_code(code: &str) -> bool {
129 let normalized = normalize_code(code);
130 normalized.len() > 3
132 && normalized.ends_with("US")
133 && normalized[..normalized.len() - 2]
134 .chars()
135 .all(|ch| ch.is_ascii_uppercase())
136}
137
138pub trait HasInstrumentConfig {
140 fn instrument_config(&self) -> InstrumentConfig;
141
142 #[inline]
143 fn price_divisor(&self) -> f64 {
144 self.instrument_config().price_divisor
145 }
146
147 #[inline]
148 fn decimal_places(&self) -> u32 {
149 self.instrument_config().decimal_places
150 }
151}
152
153pub trait InstrumentProvider: Send + Sync {
155 fn get_config(&self, from: &str, to: &str) -> InstrumentConfig;
156}
157
158#[derive(Debug, Clone, Copy, Default)]
160pub struct DefaultInstrumentProvider;
161
162impl InstrumentProvider for DefaultInstrumentProvider {
163 fn get_config(&self, from: &str, to: &str) -> InstrumentConfig {
164 resolve_instrument_config(from, to)
165 }
166}
167
168#[derive(Debug, Clone, Default)]
170pub struct OverrideInstrumentProvider {
171 overrides: HashMap<String, InstrumentConfig>,
172}
173
174impl OverrideInstrumentProvider {
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn add_override(&mut self, from: &str, to: &str, config: InstrumentConfig) -> &mut Self {
180 self.overrides.insert(pair_key(from, to), config);
181 self
182 }
183
184 pub fn remove_override(&mut self, from: &str, to: &str) -> &mut Self {
185 self.overrides.remove(&pair_key(from, to));
186 self
187 }
188
189 pub fn has_override(&self, from: &str, to: &str) -> bool {
190 self.overrides.contains_key(&pair_key(from, to))
191 }
192
193 pub fn override_count(&self) -> usize {
194 self.overrides.len()
195 }
196}
197
198impl InstrumentProvider for OverrideInstrumentProvider {
199 fn get_config(&self, from: &str, to: &str) -> InstrumentConfig {
200 self.overrides
201 .get(&pair_key(from, to))
202 .copied()
203 .unwrap_or_else(|| resolve_instrument_config(from, to))
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_currency_category() {
213 assert_eq!(CurrencyCategory::from_code("JPY"), CurrencyCategory::Jpy);
214 assert_eq!(CurrencyCategory::from_code("jpy"), CurrencyCategory::Jpy);
215 assert_eq!(CurrencyCategory::from_code("XAU"), CurrencyCategory::Metal);
216 assert_eq!(
217 CurrencyCategory::from_code("USD"),
218 CurrencyCategory::Standard
219 );
220 assert_eq!(
221 CurrencyCategory::from_code("XYZ"),
222 CurrencyCategory::Unknown
223 );
224 }
225
226 #[test]
227 fn test_resolve_config() {
228 assert_eq!(
229 resolve_instrument_config("EUR", "USD"),
230 InstrumentConfig::STANDARD
231 );
232 assert_eq!(
233 resolve_instrument_config("USD", "JPY"),
234 InstrumentConfig::JPY
235 );
236 assert_eq!(
237 resolve_instrument_config("XAU", "USD"),
238 InstrumentConfig::METALS
239 );
240 assert_eq!(
241 resolve_instrument_config("USD", "RUB"),
242 InstrumentConfig::RUB
243 );
244 assert_eq!(
245 resolve_instrument_config("DE40", "USD"),
246 InstrumentConfig::INDEX
247 );
248 assert_eq!(
249 resolve_instrument_config("DEUIDX", "EUR"),
250 InstrumentConfig::INDEX
251 );
252 assert_eq!(
253 resolve_instrument_config("AAPLUS", "USD"),
254 InstrumentConfig::INDEX
255 );
256 }
257
258 #[test]
259 fn test_override_provider() {
260 let mut provider = OverrideInstrumentProvider::new();
261 provider.add_override("BTC", "USD", InstrumentConfig::new(100.0, 2));
262
263 assert_eq!(provider.get_config("BTC", "USD").price_divisor, 100.0);
264 assert_eq!(
265 provider.get_config("EUR", "USD"),
266 InstrumentConfig::STANDARD
267 );
268 }
269
270 #[test]
271 fn test_override_provider_key_collision_regression() {
272 let mut provider = OverrideInstrumentProvider::new();
273 let first = InstrumentConfig::new(10.0, 1);
274 let second = InstrumentConfig::new(20.0, 2);
275
276 provider.add_override("AB", "CDE", first);
277 provider.add_override("ABC", "DE", second);
278
279 assert_eq!(provider.get_config("AB", "CDE"), first);
280 assert_eq!(provider.get_config("ABC", "DE"), second);
281 }
282
283 #[test]
284 fn test_config_constants() {
285 assert_eq!(InstrumentConfig::STANDARD.price_divisor, 100_000.0);
286 assert_eq!(InstrumentConfig::JPY.price_divisor, 1_000.0);
287 assert_eq!(InstrumentConfig::METALS.price_divisor, 1_000.0);
288 assert_eq!(InstrumentConfig::INDEX.price_divisor, 1_000.0);
289 }
290}