nautilus_interactive_brokers/common/
futures.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct FuturesSymbol<'a> {
21 pub root: &'a str,
23 pub month_code: char,
25 pub year_digits: &'a str,
27}
28
29#[must_use]
32pub fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol<'_>> {
33 for (month_pos, month_code) in symbol.char_indices().rev() {
34 if futures_month(month_code).is_none() {
35 continue;
36 }
37
38 let year_digits = &symbol[month_pos + month_code.len_utf8()..];
39
40 if !matches!(year_digits.len(), 1 | 2 | 4)
41 || !year_digits.bytes().all(|b| b.is_ascii_digit())
42 || month_pos == 0
43 {
44 continue;
45 }
46
47 return Some(FuturesSymbol {
48 root: &symbol[..month_pos],
49 month_code,
50 year_digits,
51 });
52 }
53
54 None
55}
56
57#[must_use]
59pub fn format_futures_symbol(
60 root: &str,
61 month_code: char,
62 year: i32,
63 year_digits: u8,
64) -> Option<String> {
65 if root.is_empty() || futures_month(month_code).is_none() || !(0..=9999).contains(&year) {
66 return None;
67 }
68
69 let year = match year_digits {
70 1 => format!("{}", year % 10),
71 2 => format!("{:02}", year % 100),
72 4 => format!("{year:04}"),
73 _ => return None,
74 };
75
76 Some(format!("{root}{month_code}{year}"))
77}
78
79#[must_use]
87pub fn resolve_futures_year(year_digits: &str, reference_year: i32) -> Option<i32> {
88 if reference_year < 0 || !year_digits.bytes().all(|b| b.is_ascii_digit()) {
89 return None;
90 }
91
92 let partial = year_digits.parse::<i32>().ok()?;
93 match year_digits.len() {
94 1 => {
95 let mut year = reference_year - reference_year.rem_euclid(10) + partial;
96 if year < reference_year {
97 year += 10;
98 }
99
100 Some(year)
101 }
102 2 => {
103 let mut year = reference_year - reference_year.rem_euclid(100) + partial;
104 if year < reference_year - 30 {
105 year += 100;
106 } else if year > reference_year + 69 {
107 year -= 100;
108 }
109
110 Some(year)
111 }
112 4 => Some(partial),
113 _ => None,
114 }
115}
116
117#[must_use]
119pub const fn futures_month_code(month: u8) -> Option<char> {
120 match month {
121 1 => Some('F'),
122 2 => Some('G'),
123 3 => Some('H'),
124 4 => Some('J'),
125 5 => Some('K'),
126 6 => Some('M'),
127 7 => Some('N'),
128 8 => Some('Q'),
129 9 => Some('U'),
130 10 => Some('V'),
131 11 => Some('X'),
132 12 => Some('Z'),
133 _ => None,
134 }
135}
136
137#[must_use]
139pub const fn futures_month(month_code: char) -> Option<u8> {
140 match month_code {
141 'F' => Some(1),
142 'G' => Some(2),
143 'H' => Some(3),
144 'J' => Some(4),
145 'K' => Some(5),
146 'M' => Some(6),
147 'N' => Some(7),
148 'Q' => Some(8),
149 'U' => Some(9),
150 'V' => Some(10),
151 'X' => Some(11),
152 'Z' => Some(12),
153 _ => None,
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use rstest::rstest;
160
161 use super::*;
162
163 #[rstest]
164 #[case("ESZ6", "ES", 'Z', "6")]
165 #[case("ESZ26", "ES", 'Z', "26")]
166 #[case("ESZ2026", "ES", 'Z', "2026")]
167 #[case("EX2G3", "EX2", 'G', "3")]
168 fn test_parse_futures_symbol(
169 #[case] value: &str,
170 #[case] root: &str,
171 #[case] month_code: char,
172 #[case] year_digits: &str,
173 ) {
174 assert_eq!(
175 parse_futures_symbol(value),
176 Some(FuturesSymbol {
177 root,
178 month_code,
179 year_digits,
180 })
181 );
182 }
183
184 #[rstest]
185 #[case(1, "ESZ6")]
186 #[case(2, "ESZ26")]
187 #[case(4, "ESZ2026")]
188 fn test_format_futures_symbol(#[case] digits: u8, #[case] expected: &str) {
189 assert_eq!(
190 format_futures_symbol("ES", 'Z', 2026, digits).as_deref(),
191 Some(expected)
192 );
193 }
194
195 #[rstest]
196 #[case("6", 2026, 2026)]
197 #[case("5", 2026, 2035)]
198 #[case("26", 2026, 2026)]
199 #[case("25", 2026, 2025)]
200 #[case("96", 2026, 1996)]
201 #[case("95", 2026, 2095)]
202 #[case("00", 2099, 2100)]
203 #[case("01", 2099, 2101)]
204 #[case("2026", 2099, 2026)]
205 fn test_resolve_futures_year(
206 #[case] digits: &str,
207 #[case] reference_year: i32,
208 #[case] expected: i32,
209 ) {
210 assert_eq!(resolve_futures_year(digits, reference_year), Some(expected));
211 }
212
213 #[rstest]
214 #[case(1, 'F')]
215 #[case(6, 'M')]
216 #[case(12, 'Z')]
217 fn test_futures_month_round_trip(#[case] month: u8, #[case] code: char) {
218 assert_eq!(futures_month_code(month), Some(code));
219 assert_eq!(futures_month(code), Some(month));
220 }
221
222 #[rstest]
223 #[case("")]
224 #[case("ES")]
225 #[case("ESZ")]
226 #[case("ESZ123")]
227 #[case("Z26")]
228 fn test_parse_futures_symbol_rejects_invalid_values(#[case] value: &str) {
229 assert_eq!(parse_futures_symbol(value), None);
230 }
231}