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_ascii};
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_ascii(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    #[cfg_attr(not(feature = "python"), 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 '{chain_name}' in venue '{venue_str}'")
149            })?;
150
151            // Get the DexType enum
152            let dex_type = DexType::from_dex_name(dex_id)
153                .ok_or_else(|| anyhow::anyhow!("Invalid DEX '{dex_id}' in venue '{venue_str}'"))?;
154
155            Ok((chain.name, dex_type))
156        } else {
157            anyhow::bail!("Venue '{venue_str}' is not a DEX venue (expected format 'Chain:DexId')")
158        }
159    }
160}
161
162impl Debug for Venue {
163    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
164        write!(f, "{:?}", self.0)
165    }
166}
167
168impl Display for Venue {
169    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
170        write!(f, "{}", self.0)
171    }
172}
173
174/// Validates blockchain venue format "Chain:DexId".
175///
176/// # Errors
177///
178/// Returns an error if:
179/// - Format is not "Chain:DexId" (missing colon or empty parts)
180/// - Chain or Dex is not recognized
181#[cfg(feature = "defi")]
182pub fn validate_blockchain_venue(venue_part: &str) -> anyhow::Result<()> {
183    if let Some((chain_name, dex_id)) = venue_part.split_once(':') {
184        if chain_name.is_empty() || dex_id.is_empty() {
185            anyhow::bail!("invalid blockchain venue '{venue_part}': expected format 'Chain:DexId'");
186        }
187        if Chain::from_chain_name(chain_name).is_none() {
188            anyhow::bail!(
189                "invalid blockchain venue '{venue_part}': chain '{chain_name}' not recognized"
190            );
191        }
192        if DexType::from_dex_name(dex_id).is_none() {
193            anyhow::bail!("invalid blockchain venue '{venue_part}': dex '{dex_id}' not recognized");
194        }
195        Ok(())
196    } else {
197        anyhow::bail!("invalid blockchain venue '{venue_part}': expected format 'Chain:DexId'");
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use rstest::rstest;
204
205    #[cfg(feature = "defi")]
206    use crate::defi::{Blockchain, DexType};
207    use crate::identifiers::{Venue, stubs::*};
208
209    #[rstest]
210    fn test_string_reprs(venue_binance: Venue) {
211        assert_eq!(venue_binance.as_str(), "BINANCE");
212        assert_eq!(format!("{venue_binance}"), "BINANCE");
213    }
214
215    #[cfg(feature = "defi")]
216    #[rstest]
217    fn test_blockchain_venue_valid_dex_names() {
218        // Test various valid DEX names
219        let valid_dexes = vec![
220            "UniswapV3",
221            "UniswapV2",
222            "UniswapV4",
223            "SushiSwapV2",
224            "SushiSwapV3",
225            "PancakeSwapV3",
226            "CamelotV3",
227            "CurveFinance",
228            "FluidDEX",
229            "MaverickV1",
230            "MaverickV2",
231            "BaseX",
232            "BaseSwapV2",
233            "AerodromeV1",
234            "AerodromeSlipstream",
235            "BalancerV2",
236            "BalancerV3",
237        ];
238
239        for dex_name in valid_dexes {
240            let venue_str = format!("Arbitrum:{dex_name}");
241            let venue = Venue::new(&venue_str);
242            assert_eq!(venue.to_string(), venue_str);
243        }
244    }
245    #[cfg(feature = "defi")]
246    #[rstest]
247    #[should_panic(
248        expected = "Error creating `Venue` from 'InvalidChain:UniswapV3': invalid blockchain venue 'InvalidChain:UniswapV3': chain 'InvalidChain' not recognized"
249    )]
250    fn test_blockchain_venue_invalid_chain() {
251        let _ = Venue::new("InvalidChain:UniswapV3");
252    }
253
254    #[cfg(feature = "defi")]
255    #[rstest]
256    #[should_panic(
257        expected = "Error creating `Venue` from 'Arbitrum:': invalid blockchain venue 'Arbitrum:': expected format 'Chain:DexId'"
258    )]
259    fn test_blockchain_venue_empty_dex() {
260        let _ = Venue::new("Arbitrum:");
261    }
262
263    #[cfg(feature = "defi")]
264    #[rstest]
265    fn test_regular_venue_with_blockchain_like_name_but_without_dex() {
266        // Should work fine since it doesn't contain ':'
267        let venue = Venue::new("Ethereum");
268        assert_eq!(venue.to_string(), "Ethereum");
269    }
270
271    #[cfg(feature = "defi")]
272    #[rstest]
273    #[should_panic(
274        expected = "Error creating `Venue` from 'Arbitrum:InvalidDex': invalid blockchain venue 'Arbitrum:InvalidDex': dex 'InvalidDex' not recognized"
275    )]
276    fn test_blockchain_venue_invalid_dex() {
277        let _ = Venue::new("Arbitrum:InvalidDex");
278    }
279
280    #[cfg(feature = "defi")]
281    #[rstest]
282    #[should_panic(
283        expected = "Error creating `Venue` from 'Arbitrum:uniswapv3': invalid blockchain venue 'Arbitrum:uniswapv3': dex 'uniswapv3' not recognized"
284    )]
285    fn test_blockchain_venue_dex_case_sensitive() {
286        // DEX names should be case sensitive
287        let _ = Venue::new("Arbitrum:uniswapv3");
288    }
289
290    #[cfg(feature = "defi")]
291    #[rstest]
292    fn test_blockchain_venue_various_chain_dex_combinations() {
293        // Test various valid chain:dex combinations
294        let valid_combinations = vec![
295            ("Ethereum", "UniswapV2"),
296            ("Ethereum", "BalancerV2"),
297            ("Arbitrum", "CamelotV3"),
298            ("Base", "AerodromeV1"),
299            ("Polygon", "SushiSwapV3"),
300        ];
301
302        for (chain, dex) in valid_combinations {
303            let venue_str = format!("{chain}:{dex}");
304            let venue = Venue::new(&venue_str);
305            assert_eq!(venue.to_string(), venue_str);
306        }
307    }
308
309    #[cfg(feature = "defi")]
310    #[rstest]
311    #[case("Ethereum:UniswapV3", Blockchain::Ethereum, DexType::UniswapV3)]
312    #[case("Arbitrum:CamelotV3", Blockchain::Arbitrum, DexType::CamelotV3)]
313    #[case("Base:AerodromeV1", Blockchain::Base, DexType::AerodromeV1)]
314    #[case("Polygon:SushiSwapV2", Blockchain::Polygon, DexType::SushiSwapV2)]
315    fn test_parse_dex_valid(
316        #[case] venue_str: &str,
317        #[case] expected_chain: Blockchain,
318        #[case] expected_dex: DexType,
319    ) {
320        let venue = Venue::new(venue_str);
321        let (blockchain, dex_type) = venue.parse_dex().unwrap();
322
323        assert_eq!(blockchain, expected_chain);
324        assert_eq!(dex_type, expected_dex);
325    }
326
327    #[cfg(feature = "defi")]
328    #[rstest]
329    fn test_parse_dex_non_dex_venue() {
330        let venue = Venue::new("BINANCE");
331        let result = venue.parse_dex();
332        assert!(result.is_err());
333        assert!(
334            result
335                .unwrap_err()
336                .to_string()
337                .contains("is not a DEX venue")
338        );
339    }
340
341    #[cfg(feature = "defi")]
342    #[rstest]
343    fn test_parse_dex_invalid_components() {
344        // Test invalid chain
345        let venue = Venue::from_str_unchecked("InvalidChain:UniswapV3");
346        assert!(venue.parse_dex().is_err());
347
348        // Test invalid DEX
349        let venue = Venue::from_str_unchecked("Ethereum:InvalidDex");
350        assert!(venue.parse_dex().is_err());
351    }
352}