Skip to main content

nautilus_model/identifiers/
mod.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//! Identifiers for the trading domain model.
17//!
18//! # Design notes
19//!
20//! - `TradeId` remains a fixed-size `StackStr` with a 36-character limit.
21//! - High-cardinality external IDs must not use `Ustr`, because interning
22//!   unique values grows process memory without bound.
23//! - Some identifiers still use fixed-size `repr(C)` storage because the
24//!   supported C ABI shares raw layout by value.
25//! - A deeper storage redesign is deferred until the C ABI can move to
26//!   conversion-based bindings instead of layout sharing.
27
28use serde::{Deserialize, Deserializer, Serialize, Serializer};
29
30#[macro_use]
31mod macros;
32
33pub mod account_id;
34pub mod actor_id;
35pub mod client_id;
36pub mod client_order_id;
37pub mod component_id;
38pub mod exec_algorithm_id;
39pub mod instrument_id;
40pub mod option_instrument_id;
41pub mod option_series_id;
42pub mod order_list_id;
43pub mod position_id;
44pub mod strategy_id;
45pub mod symbol;
46pub mod trade_id;
47pub mod trader_id;
48pub mod venue;
49pub mod venue_order_id;
50
51#[cfg(any(test, feature = "test-support"))]
52pub mod stubs;
53
54// Re-exports
55pub use crate::identifiers::{
56    account_id::AccountId,
57    actor_id::ActorId,
58    client_id::ClientId,
59    client_order_id::ClientOrderId,
60    component_id::ComponentId,
61    exec_algorithm_id::ExecAlgorithmId,
62    instrument_id::{GENERIC_SPREAD_ID_SEPARATOR, InstrumentId, InstrumentIdError},
63    option_instrument_id::{
64        OptionContractSpec, OptionInstrumentIdError, OptionSymbologyScheme,
65        cme_globex_option_symbol, compute_option_instrument_id, osi_option_symbol,
66    },
67    option_series_id::{OptionSeriesId, OptionSeriesIdError},
68    order_list_id::OrderListId,
69    position_id::PositionId,
70    strategy_id::{
71        StrategyId, UNASSIGNED_ORDER_ID_TAG, check_order_id_tag, normalize_order_id_tag,
72    },
73    symbol::Symbol,
74    trade_id::TradeId,
75    trader_id::TraderId,
76    venue::Venue,
77    venue_order_id::VenueOrderId,
78};
79
80/// Creates a generic spread instrument ID from `(instrument_id, ratio)` legs.
81///
82/// Sorts the legs by symbol and joins them with [`GENERIC_SPREAD_ID_SEPARATOR`], formatting a
83/// positive ratio as `(ratio)symbol` and a negative ratio as `((ratio))symbol`. For example,
84/// `MSFT.NASDAQ` with ratio 1 and `AAPL.NASDAQ` with ratio -2 produce
85/// `((2))AAPL___(1)MSFT.NASDAQ`.
86///
87/// A leg symbol that contains the separator or ends with `_` produces an ID that does not parse
88/// back into the same legs.
89///
90/// # Errors
91///
92/// Returns an error if:
93/// - Fewer than two legs are given.
94/// - A ratio is zero or `i64::MIN`.
95/// - The legs have different venues.
96pub fn new_generic_spread_id(
97    instrument_ratios: &[(InstrumentId, i64)],
98) -> anyhow::Result<InstrumentId> {
99    anyhow::ensure!(
100        instrument_ratios.len() > 1,
101        "instrument_ratios list needs to have at least 2 legs"
102    );
103
104    let first_venue = instrument_ratios[0].0.venue;
105    for (instrument_id, ratio) in instrument_ratios {
106        anyhow::ensure!(*ratio != 0, "ratio cannot be zero");
107        anyhow::ensure!(*ratio != i64::MIN, "ratio cannot be i64::MIN");
108        anyhow::ensure!(
109            instrument_id.venue == first_venue,
110            "All venues must match. Expected {first_venue}, was {}",
111            instrument_id.venue
112        );
113    }
114
115    let mut sorted_ratios = instrument_ratios.to_vec();
116    sorted_ratios.sort_by_key(|(instrument_id, _)| instrument_id.symbol);
117
118    let symbol_parts: Vec<String> = sorted_ratios
119        .into_iter()
120        .map(|(instrument_id, ratio)| {
121            if ratio > 0 {
122                format!("({ratio}){}", instrument_id.symbol)
123            } else {
124                format!("(({})){}", ratio.abs(), instrument_id.symbol)
125            }
126        })
127        .collect();
128
129    Ok(InstrumentId::new(
130        Symbol::new(symbol_parts.join(GENERIC_SPREAD_ID_SEPARATOR)),
131        first_venue,
132    ))
133}
134
135/// Parses a generic spread instrument ID into `(instrument_id, ratio)` legs.
136///
137/// Accepts the format [`new_generic_spread_id`] produces and returns the legs in the order they
138/// appear in the symbol, with a negative ratio for each `((ratio))symbol` leg. Each ratio must be
139/// non-zero and contain only ASCII digits, so a sign inside the parentheses is rejected.
140///
141/// # Errors
142///
143/// Returns an error if:
144/// - The symbol does not contain [`GENERIC_SPREAD_ID_SEPARATOR`].
145/// - A leg is not in the `(ratio)symbol` or `((ratio))symbol` format.
146pub fn parse_generic_spread_id_legs(
147    instrument_id: &InstrumentId,
148) -> anyhow::Result<Vec<(InstrumentId, i64)>> {
149    let symbol = instrument_id.symbol.as_str();
150    anyhow::ensure!(
151        symbol.contains(GENERIC_SPREAD_ID_SEPARATOR),
152        "Invalid generic spread instrument ID: {instrument_id}"
153    );
154
155    symbol
156        .split(GENERIC_SPREAD_ID_SEPARATOR)
157        .map(|component| {
158            parse_generic_spread_leg(component, instrument_id.venue)
159                .ok_or_else(|| anyhow::anyhow!("Invalid generic spread leg component: {component}"))
160        })
161        .collect()
162}
163
164fn parse_generic_spread_leg(component: &str, venue: Venue) -> Option<(InstrumentId, i64)> {
165    if let Some(rest) = component.strip_prefix("((") {
166        let (ratio, symbol) = rest.split_once("))")?;
167        return parse_generic_spread_leg_parts(ratio, symbol, venue, -1);
168    }
169
170    let rest = component.strip_prefix('(')?;
171    let (ratio, symbol) = rest.split_once(')')?;
172    parse_generic_spread_leg_parts(ratio, symbol, venue, 1)
173}
174
175fn parse_generic_spread_leg_parts(
176    ratio: &str,
177    symbol: &str,
178    venue: Venue,
179    sign: i64,
180) -> Option<(InstrumentId, i64)> {
181    // Only digit ratios are valid: `parse::<i64>` would accept a leading sign,
182    // silently flipping the sign encoded by the surrounding parentheses.
183    if !ratio.bytes().all(|b| b.is_ascii_digit()) {
184        return None;
185    }
186
187    let ratio = ratio.parse::<i64>().ok()?.checked_mul(sign)?;
188    if ratio == 0 {
189        return None;
190    }
191
192    let symbol = Symbol::new_checked(symbol).ok()?;
193    Some((InstrumentId::new(symbol, venue), ratio))
194}
195
196impl_from_str_for_identifier!(account_id::AccountId);
197impl_from_str_for_identifier!(actor_id::ActorId);
198impl_from_str_for_identifier!(client_id::ClientId);
199impl_from_str_for_identifier!(client_order_id::ClientOrderId);
200impl_from_str_for_identifier!(component_id::ComponentId);
201impl_from_str_for_identifier!(exec_algorithm_id::ExecAlgorithmId);
202impl_from_str_for_identifier!(order_list_id::OrderListId);
203impl_from_str_for_identifier!(position_id::PositionId);
204impl_from_str_for_identifier!(strategy_id::StrategyId);
205impl_from_str_for_identifier!(symbol::Symbol);
206impl_from_str_for_identifier!(trade_id::TradeId);
207impl_from_str_for_identifier!(trader_id::TraderId);
208impl_from_str_for_identifier!(venue::Venue);
209impl_from_str_for_identifier!(venue_order_id::VenueOrderId);
210
211impl_serialization_for_identifier!(account_id::AccountId);
212impl_serialization_for_identifier!(actor_id::ActorId);
213impl_serialization_for_identifier!(client_id::ClientId);
214impl_serialization_for_identifier!(client_order_id::ClientOrderId);
215impl_serialization_for_identifier!(component_id::ComponentId);
216impl_serialization_for_identifier!(exec_algorithm_id::ExecAlgorithmId);
217impl_serialization_for_identifier!(order_list_id::OrderListId);
218impl_serialization_for_identifier!(position_id::PositionId);
219impl_serialization_for_identifier!(strategy_id::StrategyId);
220impl_serialization_for_identifier!(symbol::Symbol);
221impl_serialization_for_identifier!(trader_id::TraderId);
222impl_serialization_for_identifier!(venue::Venue);
223impl_serialization_for_identifier!(venue_order_id::VenueOrderId);
224
225impl_as_ref_for_identifier!(account_id::AccountId);
226impl_as_ref_for_identifier!(actor_id::ActorId);
227impl_as_ref_for_identifier!(client_id::ClientId);
228impl_as_ref_for_identifier!(client_order_id::ClientOrderId);
229impl_as_ref_for_identifier!(component_id::ComponentId);
230impl_as_ref_for_identifier!(exec_algorithm_id::ExecAlgorithmId);
231impl_as_ref_for_identifier!(order_list_id::OrderListId);
232impl_as_ref_for_identifier!(position_id::PositionId);
233impl_as_ref_for_identifier!(strategy_id::StrategyId);
234impl_as_ref_for_identifier!(symbol::Symbol);
235impl_as_ref_for_identifier!(trader_id::TraderId);
236impl_as_ref_for_identifier!(venue::Venue);
237impl_as_ref_for_identifier!(venue_order_id::VenueOrderId);
238
239/// Print interned string cache statistics for debugging purposes.
240pub fn interned_string_stats() {
241    ustr::string_cache_iter().for_each(|s| println!("{s}"));
242}
243
244#[cfg(test)]
245mod tests {
246    use rstest::rstest;
247
248    use super::{InstrumentId, new_generic_spread_id, parse_generic_spread_id_legs};
249
250    #[rstest]
251    fn test_new_generic_spread_id_sorts_and_formats_legs() {
252        let msft = InstrumentId::from("MSFT.NASDAQ");
253        let aapl = InstrumentId::from("AAPL.NASDAQ");
254
255        let spread = new_generic_spread_id(&[(msft, 1), (aapl, -2)]).unwrap();
256
257        assert_eq!(spread, InstrumentId::from("((2))AAPL___(1)MSFT.NASDAQ"));
258    }
259
260    #[rstest]
261    #[case(
262        vec![("MSFT.NASDAQ", 1)],
263        "instrument_ratios list needs to have at least 2 legs"
264    )]
265    #[case(vec![("MSFT.NASDAQ", 0), ("AAPL.NASDAQ", 1)], "ratio cannot be zero")]
266    #[case(
267        vec![("MSFT.NASDAQ", 1), ("AAPL.XNAS", 1)],
268        "All venues must match. Expected NASDAQ, was XNAS"
269    )]
270    #[case(vec![("MSFT.NASDAQ", i64::MIN), ("AAPL.NASDAQ", 1)], "ratio cannot be i64::MIN")]
271    fn test_new_generic_spread_id_rejects_invalid_legs(
272        #[case] legs: Vec<(&str, i64)>,
273        #[case] expected: &str,
274    ) {
275        let legs: Vec<(InstrumentId, i64)> = legs
276            .into_iter()
277            .map(|(instrument_id, ratio)| (InstrumentId::from(instrument_id), ratio))
278            .collect();
279
280        let result = new_generic_spread_id(&legs);
281
282        assert_eq!(result.unwrap_err().to_string(), expected);
283    }
284
285    #[rstest]
286    #[case("((-2))AAPL___(1)MSFT.NASDAQ", "((-2))AAPL")] // Signed ratio in negative leg
287    #[case("(+1)MSFT___(2)AAPL.NASDAQ", "(+1)MSFT")] // Signed ratio in positive leg
288    #[case("(-1)MSFT___(2)AAPL.NASDAQ", "(-1)MSFT")] // Signed ratio in positive leg
289    #[case("()MSFT___(2)AAPL.NASDAQ", "()MSFT")] // Empty ratio
290    #[case("(1a)MSFT___(2)AAPL.NASDAQ", "(1a)MSFT")] // Non-digit ratio
291    fn test_parse_generic_spread_id_legs_rejects_non_digit_ratios(
292        #[case] value: &str,
293        #[case] component: &str,
294    ) {
295        let result = parse_generic_spread_id_legs(&InstrumentId::from(value));
296
297        assert_eq!(
298            result.unwrap_err().to_string(),
299            format!("Invalid generic spread leg component: {component}")
300        );
301    }
302
303    #[rstest]
304    #[case("(0)MSFT___(2)AAPL.NASDAQ", "(0)MSFT")] // Zero ratio
305    #[case("(1)___(2)AAPL.NASDAQ", "(1)")] // Empty symbol
306    #[case("(1) ___(2)AAPL.NASDAQ", "(1) ")] // Whitespace symbol
307    #[case("MSFT___(2)AAPL.NASDAQ", "MSFT")] // Missing ratio
308    #[case(
309        "(9223372036854775808)MSFT___(2)AAPL.NASDAQ",
310        "(9223372036854775808)MSFT"
311    )] // Ratio overflows i64
312    fn test_parse_generic_spread_id_legs_rejects_malformed_legs(
313        #[case] value: &str,
314        #[case] component: &str,
315    ) {
316        let result = parse_generic_spread_id_legs(&InstrumentId::from(value));
317
318        assert_eq!(
319            result.unwrap_err().to_string(),
320            format!("Invalid generic spread leg component: {component}")
321        );
322    }
323
324    #[rstest]
325    fn test_parse_generic_spread_id_legs_rejects_id_without_separator() {
326        let result = parse_generic_spread_id_legs(&InstrumentId::from("(1)MSFT.NASDAQ"));
327
328        assert_eq!(
329            result.unwrap_err().to_string(),
330            "Invalid generic spread instrument ID: (1)MSFT.NASDAQ"
331        );
332    }
333
334    #[rstest]
335    fn test_generic_spread_id_round_trip() {
336        let spread = new_generic_spread_id(&[
337            (InstrumentId::from("ESM4 P5230.XCME"), -1),
338            (InstrumentId::from("ESM4 P5250.XCME"), 1),
339        ])
340        .unwrap();
341
342        assert_eq!(
343            parse_generic_spread_id_legs(&spread).unwrap(),
344            vec![
345                (InstrumentId::from("ESM4 P5230.XCME"), -1),
346                (InstrumentId::from("ESM4 P5250.XCME"), 1),
347            ]
348        );
349    }
350}