Skip to main content

dukascopy_fx/core/
instrument.rs

1//! Instrument configuration for price scaling and decimal precision.
2
3use std::collections::HashMap;
4
5/// Price divisor for standard currency pairs (5 decimal places)
6pub const DIVISOR_5_DECIMALS: f64 = 100_000.0;
7
8/// Price divisor for 3 decimal place instruments (JPY, metals, RUB)
9pub const DIVISOR_3_DECIMALS: f64 = 1_000.0;
10
11/// Price divisor for 2 decimal place instruments
12pub const DIVISOR_2_DECIMALS: f64 = 100.0;
13
14/// Configuration for an instrument's price scaling
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct InstrumentConfig {
17    /// Divisor to convert raw tick price to actual price
18    pub price_divisor: f64,
19    /// Number of decimal places for the instrument
20    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    /// Standard forex pair configuration (5 decimal places)
33    pub const STANDARD: Self = Self::new(DIVISOR_5_DECIMALS, 5);
34
35    /// JPY forex pair configuration (3 decimal places)
36    pub const JPY: Self = Self::new(DIVISOR_3_DECIMALS, 3);
37
38    /// Metals configuration (3 decimal places)
39    pub const METALS: Self = Self::new(DIVISOR_3_DECIMALS, 3);
40
41    /// RUB pairs configuration (3 decimal places)
42    pub const RUB: Self = Self::new(DIVISOR_3_DECIMALS, 3);
43
44    /// Index configuration (raw index ticks are typically scaled by 1000).
45    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/// Categories of currencies/instruments
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CurrencyCategory {
57    Standard,
58    Jpy,
59    Rub,
60    Metal,
61    Unknown,
62}
63
64impl CurrencyCategory {
65    /// Categorizes a currency code
66    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
89/// Resolves the instrument configuration for a currency pair.
90pub 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    // Use an explicit separator to avoid key collisions for variable-length codes.
118    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    // Dukascopy stock CFDs typically use symbols like AAPLUS, MSFTUS, NVDAUS.
131    normalized.len() > 3
132        && normalized.ends_with("US")
133        && normalized[..normalized.len() - 2]
134            .chars()
135            .all(|ch| ch.is_ascii_uppercase())
136}
137
138/// Trait for types that can provide instrument configuration
139pub 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
153/// Trait for providing instrument configurations.
154pub trait InstrumentProvider: Send + Sync {
155    fn get_config(&self, from: &str, to: &str) -> InstrumentConfig;
156}
157
158/// Default instrument provider using automatic detection.
159#[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/// Instrument provider with custom overrides.
169#[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}