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.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.
115    pub fn from_code(code: &str) -> anyhow::Result<Self> {
116        let map_guard = VENUE_MAP.lock();
117        map_guard
118            .get(code)
119            .copied()
120            .ok_or_else(|| anyhow::anyhow!("Unknown venue code: {code}"))
121    }
122
123    #[must_use]
124    pub fn synthetic() -> Self {
125        Self::new(SYNTHETIC_VENUE)
126    }
127
128    #[must_use]
129    pub fn is_synthetic(&self) -> bool {
130        self.0 == SYNTHETIC_VENUE
131    }
132
133    /// Returns true if the venue represents a decentralized exchange (contains ':').
134    #[cfg(feature = "defi")]
135    #[must_use]
136    pub fn is_dex(&self) -> bool {
137        self.0.as_str().contains(':')
138    }
139
140    #[cfg(feature = "defi")]
141    /// Parses a venue string to extract blockchain and DEX type information.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if:
146    /// - The venue string is not in the format "chain:dex"
147    /// - The chain name is not recognized
148    /// - The DEX name is not recognized
149    pub fn parse_dex(&self) -> anyhow::Result<(Blockchain, DexType)> {
150        let venue_str = self.as_str();
151        let Some((chain_name, dex_id)) = venue_str.split_once(':') else {
152            anyhow::bail!("Venue '{venue_str}' is not a DEX venue (expected format 'Chain:DexId')")
153        };
154
155        let chain = Chain::from_chain_name(chain_name).ok_or_else(|| {
156            anyhow::anyhow!("Invalid chain '{chain_name}' in venue '{venue_str}'")
157        })?;
158        let dex_type = DexType::from_dex_name(dex_id)
159            .ok_or_else(|| anyhow::anyhow!("Invalid DEX '{dex_id}' in venue '{venue_str}'"))?;
160
161        Ok((chain.name, dex_type))
162    }
163}
164
165impl Debug for Venue {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        write!(f, "\"{}\"", self.0)
168    }
169}
170
171impl Display for Venue {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        write!(f, "{}", self.0)
174    }
175}
176
177/// Validates blockchain venue format "Chain:DexId".
178///
179/// # Errors
180///
181/// Returns an error if:
182/// - Format is not "Chain:DexId" (missing colon or empty parts)
183/// - Chain or Dex is not recognized
184#[cfg(feature = "defi")]
185pub fn validate_blockchain_venue(venue_part: &str) -> CorrectnessResult<()> {
186    let invalid_format = || CorrectnessError::PredicateViolation {
187        message: format!("invalid blockchain venue '{venue_part}': expected format 'Chain:DexId'"),
188    };
189
190    let Some((chain_name, dex_id)) = venue_part.split_once(':') else {
191        return Err(invalid_format());
192    };
193
194    if chain_name.is_empty() || dex_id.is_empty() {
195        return Err(invalid_format());
196    }
197
198    if Chain::from_chain_name(chain_name).is_none() {
199        return Err(CorrectnessError::PredicateViolation {
200            message: format!(
201                "invalid blockchain venue '{venue_part}': chain '{chain_name}' not recognized"
202            ),
203        });
204    }
205
206    if DexType::from_dex_name(dex_id).is_none() {
207        return Err(CorrectnessError::PredicateViolation {
208            message: format!(
209                "invalid blockchain venue '{venue_part}': dex '{dex_id}' not recognized"
210            ),
211        });
212    }
213
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use nautilus_core::correctness::CorrectnessError;
220    use rstest::rstest;
221
222    #[cfg(feature = "defi")]
223    use crate::defi::{Blockchain, DexType};
224    use crate::identifiers::{Venue, stubs::*};
225
226    #[rstest]
227    fn test_string_reprs(venue_binance: Venue) {
228        assert_eq!(venue_binance.as_str(), "BINANCE");
229        assert_eq!(format!("{venue_binance}"), "BINANCE");
230    }
231
232    #[rstest]
233    fn test_new_checked_returns_typed_error_with_stable_display() {
234        let error = Venue::new_checked("").unwrap_err();
235
236        assert_eq!(
237            error,
238            CorrectnessError::EmptyString {
239                param: "value".to_string(),
240            }
241        );
242        assert_eq!(error.to_string(), "invalid string for 'value', was empty");
243    }
244
245    #[rstest]
246    fn test_from_code_returns_mapped_venue() {
247        assert_eq!(Venue::from_code("XCME").unwrap(), Venue::XCME());
248    }
249
250    #[rstest]
251    fn test_from_code_rejects_unknown_code() {
252        let error = Venue::from_code("UNKNOWN").unwrap_err();
253        assert_eq!(error.to_string(), "Unknown venue code: UNKNOWN");
254    }
255
256    #[cfg(feature = "defi")]
257    #[rstest]
258    #[case(
259        "Arbitrum:",
260        "invalid blockchain venue 'Arbitrum:': expected format 'Chain:DexId'"
261    )]
262    #[case(
263        "InvalidChain:UniswapV3",
264        "invalid blockchain venue 'InvalidChain:UniswapV3': chain 'InvalidChain' not recognized"
265    )]
266    #[case(
267        "Arbitrum:InvalidDex",
268        "invalid blockchain venue 'Arbitrum:InvalidDex': dex 'InvalidDex' not recognized"
269    )]
270    #[case(
271        "no-colon",
272        "invalid blockchain venue 'no-colon': expected format 'Chain:DexId'"
273    )]
274    fn test_validate_blockchain_venue_returns_typed_error_with_stable_display(
275        #[case] input: &str,
276        #[case] expected_message: &str,
277    ) {
278        let error = super::validate_blockchain_venue(input).unwrap_err();
279        assert_eq!(
280            error,
281            CorrectnessError::PredicateViolation {
282                message: expected_message.to_string(),
283            }
284        );
285        assert_eq!(error.to_string(), expected_message);
286    }
287
288    #[cfg(feature = "defi")]
289    #[rstest]
290    fn test_blockchain_venue_valid_dex_names() {
291        let valid_dexes = [
292            "UniswapV3",
293            "UniswapV2",
294            "UniswapV4",
295            "SushiSwapV2",
296            "SushiSwapV3",
297            "PancakeSwapV3",
298            "CamelotV3",
299            "CurveFinance",
300            "FluidDEX",
301            "MaverickV1",
302            "MaverickV2",
303            "BaseX",
304            "BaseSwapV2",
305            "AerodromeV1",
306            "AerodromeSlipstream",
307            "BalancerV2",
308            "BalancerV3",
309        ];
310
311        for dex_name in valid_dexes {
312            let venue_str = format!("Arbitrum:{dex_name}");
313            let venue = Venue::new(&venue_str);
314            assert_eq!(venue.to_string(), venue_str);
315        }
316    }
317    #[cfg(feature = "defi")]
318    #[rstest]
319    #[should_panic(
320        expected = "Error creating `Venue` from 'InvalidChain:UniswapV3': invalid blockchain venue 'InvalidChain:UniswapV3': chain 'InvalidChain' not recognized"
321    )]
322    fn test_blockchain_venue_invalid_chain() {
323        let _ = Venue::new("InvalidChain:UniswapV3");
324    }
325
326    #[cfg(feature = "defi")]
327    #[rstest]
328    #[should_panic(
329        expected = "Error creating `Venue` from 'Arbitrum:': invalid blockchain venue 'Arbitrum:': expected format 'Chain:DexId'"
330    )]
331    fn test_blockchain_venue_empty_dex() {
332        let _ = Venue::new("Arbitrum:");
333    }
334
335    #[cfg(feature = "defi")]
336    #[rstest]
337    fn test_regular_venue_with_blockchain_like_name_but_without_dex() {
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        let _ = Venue::new("Arbitrum:uniswapv3");
358    }
359
360    #[cfg(feature = "defi")]
361    #[rstest]
362    fn test_blockchain_venue_various_chain_dex_combinations() {
363        let valid_combinations = [
364            ("Ethereum", "UniswapV2"),
365            ("Ethereum", "BalancerV2"),
366            ("Arbitrum", "CamelotV3"),
367            ("Base", "AerodromeV1"),
368            ("Polygon", "SushiSwapV3"),
369        ];
370
371        for (chain, dex) in valid_combinations {
372            let venue_str = format!("{chain}:{dex}");
373            let venue = Venue::new(&venue_str);
374            assert_eq!(venue.to_string(), venue_str);
375        }
376    }
377
378    #[cfg(feature = "defi")]
379    #[rstest]
380    #[case("Ethereum:UniswapV3", Blockchain::Ethereum, DexType::UniswapV3)]
381    #[case("Arbitrum:CamelotV3", Blockchain::Arbitrum, DexType::CamelotV3)]
382    #[case("Base:AerodromeV1", Blockchain::Base, DexType::AerodromeV1)]
383    #[case("Polygon:SushiSwapV2", Blockchain::Polygon, DexType::SushiSwapV2)]
384    fn test_parse_dex_valid(
385        #[case] venue_str: &str,
386        #[case] expected_chain: Blockchain,
387        #[case] expected_dex: DexType,
388    ) {
389        let venue = Venue::new(venue_str);
390        let (blockchain, dex_type) = venue.parse_dex().unwrap();
391
392        assert_eq!(blockchain, expected_chain);
393        assert_eq!(dex_type, expected_dex);
394    }
395
396    #[cfg(feature = "defi")]
397    #[rstest]
398    fn test_parse_dex_non_dex_venue() {
399        let venue = Venue::new("BINANCE");
400        assert!(
401            venue
402                .parse_dex()
403                .unwrap_err()
404                .to_string()
405                .contains("is not a DEX venue")
406        );
407    }
408
409    #[cfg(feature = "defi")]
410    #[rstest]
411    #[case("InvalidChain:UniswapV3")]
412    #[case("Ethereum:InvalidDex")]
413    fn test_parse_dex_invalid_component(#[case] value: &str) {
414        let venue = Venue::from_str_unchecked(value);
415        assert!(venue.parse_dex().is_err());
416    }
417}