Skip to main content

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