nautilus_model/identifiers/
venue.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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//! Represents a valid trading venue ID.
17
18use std::{
19    fmt::{Debug, Display, Formatter},
20    hash::Hash,
21};
22
23use nautilus_core::correctness::{FAILED, check_valid_string};
24use ustr::Ustr;
25
26#[cfg(feature = "defi")]
27use crate::defi::{Blockchain, Chain, DexType};
28use crate::venues::VENUE_MAP;
29
30pub const SYNTHETIC_VENUE: &str = "SYNTH";
31
32/// Represents a valid trading venue ID.
33#[repr(C)]
34#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
38)]
39pub struct Venue(Ustr);
40
41impl Venue {
42    /// Creates a new [`Venue`] instance with correctness checking.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if `value` is not a valid string.
47    ///
48    /// # Notes
49    ///
50    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
51    pub fn new_checked<T: AsRef<str>>(value: T) -> anyhow::Result<Self> {
52        let value = value.as_ref();
53        check_valid_string(value, stringify!(value))?;
54
55        #[cfg(feature = "defi")]
56        if value.contains(':')
57            && let Err(e) = validate_blockchain_venue(value)
58        {
59            anyhow::bail!("Error creating `Venue` from '{value}': {e}");
60        }
61
62        Ok(Self(Ustr::from(value)))
63    }
64
65    /// Creates a new [`Venue`] instance.
66    ///
67    /// # Panics
68    ///
69    /// Panics if `value` is not a valid string.
70    pub fn new<T: AsRef<str>>(value: T) -> Self {
71        Self::new_checked(value).expect(FAILED)
72    }
73
74    /// Sets the inner identifier value.
75    #[allow(dead_code)]
76    pub(crate) fn set_inner(&mut self, value: &str) {
77        self.0 = Ustr::from(value);
78    }
79
80    /// Returns the inner identifier value.
81    #[must_use]
82    pub fn inner(&self) -> Ustr {
83        self.0
84    }
85
86    /// Returns the inner value as a string slice.
87    #[must_use]
88    pub fn as_str(&self) -> &str {
89        self.0.as_str()
90    }
91
92    #[must_use]
93    pub fn from_str_unchecked<T: AsRef<str>>(s: T) -> Self {
94        Self(Ustr::from(s.as_ref()))
95    }
96
97    #[must_use]
98    pub const fn from_ustr_unchecked(s: Ustr) -> Self {
99        Self(s)
100    }
101
102    /// # Errors
103    ///
104    /// Returns an error if the venue code is unknown or lock on venue map fails.
105    pub fn from_code(code: &str) -> anyhow::Result<Self> {
106        let map_guard = VENUE_MAP
107            .lock()
108            .map_err(|e| anyhow::anyhow!("Error acquiring lock on `VENUE_MAP`: {e}"))?;
109        map_guard
110            .get(code)
111            .copied()
112            .ok_or_else(|| anyhow::anyhow!("Unknown venue code: {code}"))
113    }
114
115    #[must_use]
116    pub fn synthetic() -> Self {
117        // SAFETY: Unwrap safe as using known synthetic venue constant
118        Self::new(SYNTHETIC_VENUE)
119    }
120
121    #[must_use]
122    pub fn is_synthetic(&self) -> bool {
123        self.0.as_str() == SYNTHETIC_VENUE
124    }
125
126    /// Returns true if the venue represents a decentralized exchange (contains ':').
127    #[cfg(feature = "defi")]
128    #[must_use]
129    pub fn is_dex(&self) -> bool {
130        self.0.as_str().contains(':')
131    }
132
133    #[cfg(feature = "defi")]
134    /// Parses a venue string to extract blockchain and DEX type information.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if:
139    /// - The venue string is not in the format "chain:dex"
140    /// - The chain name is not recognized
141    /// - The DEX name is not recognized
142    pub fn parse_dex(&self) -> anyhow::Result<(Blockchain, DexType)> {
143        let venue_str = self.as_str();
144
145        if let Some((chain_name, dex_id)) = venue_str.split_once(':') {
146            // Get the chain reference and extract the Blockchain enum
147            let chain = Chain::from_chain_name(chain_name).ok_or_else(|| {
148                anyhow::anyhow!("Invalid chain '{}' in venue '{}'", chain_name, venue_str)
149            })?;
150
151            // Get the DexType enum
152            let dex_type = DexType::from_dex_name(dex_id).ok_or_else(|| {
153                anyhow::anyhow!("Invalid DEX '{}' in venue '{}'", dex_id, venue_str)
154            })?;
155
156            Ok((chain.name, dex_type))
157        } else {
158            anyhow::bail!(
159                "Venue '{}' is not a DEX venue (expected format 'Chain:DexId')",
160                venue_str
161            )
162        }
163    }
164}
165
166impl Debug for Venue {
167    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
168        write!(f, "{:?}", self.0)
169    }
170}
171
172impl Display for Venue {
173    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
174        write!(f, "{}", self.0)
175    }
176}
177
178/// Validates blockchain venue format "Chain:DexId".
179///
180/// # Errors
181///
182/// Returns an error if:
183/// - Format is not "Chain:DexId" (missing colon or empty parts)
184/// - Chain or Dex is not recognized
185#[cfg(feature = "defi")]
186pub fn validate_blockchain_venue(venue_part: &str) -> anyhow::Result<()> {
187    if let Some((chain_name, dex_id)) = venue_part.split_once(':') {
188        if chain_name.is_empty() || dex_id.is_empty() {
189            anyhow::bail!(
190                "invalid blockchain venue '{}': expected format 'Chain:DexId'",
191                venue_part
192            );
193        }
194        if Chain::from_chain_name(chain_name).is_none() {
195            anyhow::bail!(
196                "invalid blockchain venue '{}': chain '{}' not recognized",
197                venue_part,
198                chain_name
199            );
200        }
201        if DexType::from_dex_name(dex_id).is_none() {
202            anyhow::bail!(
203                "invalid blockchain venue '{}': dex '{}' not recognized",
204                venue_part,
205                dex_id
206            );
207        }
208        Ok(())
209    } else {
210        anyhow::bail!(
211            "invalid blockchain venue '{}': expected format 'Chain:DexId'",
212            venue_part
213        );
214    }
215}
216
217////////////////////////////////////////////////////////////////////////////////
218// Tests
219////////////////////////////////////////////////////////////////////////////////
220#[cfg(test)]
221mod tests {
222    use rstest::rstest;
223
224    #[cfg(feature = "defi")]
225    use crate::defi::{Blockchain, DexType};
226    use crate::identifiers::{Venue, stubs::*};
227
228    #[rstest]
229    fn test_string_reprs(venue_binance: Venue) {
230        assert_eq!(venue_binance.as_str(), "BINANCE");
231        assert_eq!(format!("{venue_binance}"), "BINANCE");
232    }
233
234    #[cfg(feature = "defi")]
235    #[rstest]
236    fn test_blockchain_venue_valid_dex_names() {
237        // Test various valid DEX names
238        let valid_dexes = vec![
239            "UniswapV3",
240            "UniswapV2",
241            "UniswapV4",
242            "SushiSwapV2",
243            "SushiSwapV3",
244            "PancakeSwapV3",
245            "CamelotV3",
246            "CurveFinance",
247            "FluidDEX",
248            "MaverickV1",
249            "MaverickV2",
250            "BaseX",
251            "BaseSwapV2",
252            "AerodromeV1",
253            "AerodromeSlipstream",
254            "BalancerV2",
255            "BalancerV3",
256        ];
257
258        for dex_name in valid_dexes {
259            let venue_str = format!("Arbitrum:{dex_name}");
260            let venue = Venue::new(&venue_str);
261            assert_eq!(venue.to_string(), venue_str);
262        }
263    }
264    #[cfg(feature = "defi")]
265    #[rstest]
266    #[should_panic(
267        expected = "Error creating `Venue` from 'InvalidChain:UniswapV3': invalid blockchain venue 'InvalidChain:UniswapV3': chain 'InvalidChain' not recognized"
268    )]
269    fn test_blockchain_venue_invalid_chain() {
270        let _ = Venue::new("InvalidChain:UniswapV3");
271    }
272
273    #[cfg(feature = "defi")]
274    #[rstest]
275    #[should_panic(
276        expected = "Error creating `Venue` from 'Arbitrum:': invalid blockchain venue 'Arbitrum:': expected format 'Chain:DexId'"
277    )]
278    fn test_blockchain_venue_empty_dex() {
279        let _ = Venue::new("Arbitrum:");
280    }
281
282    #[cfg(feature = "defi")]
283    #[rstest]
284    fn test_regular_venue_with_blockchain_like_name_but_without_dex() {
285        // Should work fine since it doesn't contain ':'
286        let venue = Venue::new("Ethereum");
287        assert_eq!(venue.to_string(), "Ethereum");
288    }
289
290    #[cfg(feature = "defi")]
291    #[rstest]
292    #[should_panic(
293        expected = "Error creating `Venue` from 'Arbitrum:InvalidDex': invalid blockchain venue 'Arbitrum:InvalidDex': dex 'InvalidDex' not recognized"
294    )]
295    fn test_blockchain_venue_invalid_dex() {
296        let _ = Venue::new("Arbitrum:InvalidDex");
297    }
298
299    #[cfg(feature = "defi")]
300    #[rstest]
301    #[should_panic(
302        expected = "Error creating `Venue` from 'Arbitrum:uniswapv3': invalid blockchain venue 'Arbitrum:uniswapv3': dex 'uniswapv3' not recognized"
303    )]
304    fn test_blockchain_venue_dex_case_sensitive() {
305        // DEX names should be case sensitive
306        let _ = Venue::new("Arbitrum:uniswapv3");
307    }
308
309    #[cfg(feature = "defi")]
310    #[rstest]
311    fn test_blockchain_venue_various_chain_dex_combinations() {
312        // Test various valid chain:dex combinations
313        let valid_combinations = vec![
314            ("Ethereum", "UniswapV2"),
315            ("Ethereum", "BalancerV2"),
316            ("Arbitrum", "CamelotV3"),
317            ("Base", "AerodromeV1"),
318            ("Polygon", "SushiSwapV3"),
319        ];
320
321        for (chain, dex) in valid_combinations {
322            let venue_str = format!("{chain}:{dex}");
323            let venue = Venue::new(&venue_str);
324            assert_eq!(venue.to_string(), venue_str);
325        }
326    }
327
328    #[cfg(feature = "defi")]
329    #[rstest]
330    #[case("Ethereum:UniswapV3", Blockchain::Ethereum, DexType::UniswapV3)]
331    #[case("Arbitrum:CamelotV3", Blockchain::Arbitrum, DexType::CamelotV3)]
332    #[case("Base:AerodromeV1", Blockchain::Base, DexType::AerodromeV1)]
333    #[case("Polygon:SushiSwapV2", Blockchain::Polygon, DexType::SushiSwapV2)]
334    fn test_parse_dex_valid(
335        #[case] venue_str: &str,
336        #[case] expected_chain: Blockchain,
337        #[case] expected_dex: DexType,
338    ) {
339        let venue = Venue::new(venue_str);
340        let (blockchain, dex_type) = venue.parse_dex().unwrap();
341
342        assert_eq!(blockchain, expected_chain);
343        assert_eq!(dex_type, expected_dex);
344    }
345
346    #[cfg(feature = "defi")]
347    #[rstest]
348    fn test_parse_dex_non_dex_venue() {
349        let venue = Venue::new("BINANCE");
350        let result = venue.parse_dex();
351        assert!(result.is_err());
352        assert!(
353            result
354                .unwrap_err()
355                .to_string()
356                .contains("is not a DEX venue")
357        );
358    }
359
360    #[cfg(feature = "defi")]
361    #[rstest]
362    fn test_parse_dex_invalid_components() {
363        // Test invalid chain
364        let venue = Venue::from_str_unchecked("InvalidChain:UniswapV3");
365        assert!(venue.parse_dex().is_err());
366
367        // Test invalid DEX
368        let venue = Venue::from_str_unchecked("Ethereum:InvalidDex");
369        assert!(venue.parse_dex().is_err());
370    }
371}