Skip to main content

nautilus_interactive_brokers/common/
futures.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Futures symbol parsing and formatting.
17
18/// The parsed components of a futures symbol.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct FuturesSymbol<'a> {
21    /// Symbol root before the month code.
22    pub root: &'a str,
23    /// Standard futures month code.
24    pub month_code: char,
25    /// One-, two-, or four-digit year suffix.
26    pub year_digits: &'a str,
27}
28
29/// Parses a single-leg futures symbol such as `ESZ6`, `ESZ26`, or `ESZ2026`.
30/// Callers must split multi-leg symbols before parsing.
31#[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/// Formats a futures symbol with one, two, or four year digits.
58#[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/// Resolves one-, two-, or four-digit futures years against a reference year.
80///
81/// A single digit resolves to the nearest matching year at or after the reference year,
82/// matching venue listing practice. Two digits carry the century-local year and resolve
83/// inside a window from 30 years before to 69 years after the reference year, so a
84/// recently expired id (`ESZ25` referenced in 2026) resolves to its actual year instead
85/// of a century ahead. Four digits are exact.
86#[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/// Returns the standard futures month code for a calendar month.
118#[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/// Returns the calendar month for a standard futures month code.
138#[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}