Skip to main content

nautilus_model/defi/
dex.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
16use std::{borrow::Cow, fmt::Display, str::FromStr, sync::Arc};
17
18use alloy_primitives::{Address, keccak256};
19use nautilus_core::{
20    correctness::{CorrectnessError, CorrectnessResultExt, FAILED},
21    hex,
22};
23use serde::{Deserialize, Serialize};
24use strum::{Display, EnumIter, EnumString};
25
26use crate::{
27    defi::{amm::Pool, chain::Chain, validation::validate_address},
28    enums::CurrencyType,
29    instruments::{Instrument, any::InstrumentAny, currency_pair::CurrencyPair},
30    types::{currency::Currency, fixed::FIXED_PRECISION, price::Price, quantity::Quantity},
31};
32
33/// Represents different types of Automated Market Makers (AMMs) in DeFi protocols.
34#[derive(
35    Debug,
36    Clone,
37    Copy,
38    Hash,
39    PartialEq,
40    Eq,
41    Serialize,
42    Deserialize,
43    strum::EnumString,
44    strum::Display,
45    strum::EnumIter,
46)]
47#[cfg_attr(
48    feature = "python",
49    pyo3::pyclass(
50        frozen,
51        eq,
52        eq_int,
53        module = "nautilus_trader.model",
54        from_py_object,
55        rename_all = "SCREAMING_SNAKE_CASE",
56    )
57)]
58#[cfg_attr(feature = "python", pyo3_stub_gen::derive::gen_stub_pyclass_enum)]
59#[non_exhaustive]
60pub enum AmmType {
61    /// Constant Product Automated Market Maker.
62    CPAMM,
63    /// Concentrated Liquidity Automated Market Maker.
64    CLAMM,
65    /// Concentrated liquidity AMM **with hooks** (e.g. upcoming Uniswap v4).
66    CLAMEnhanced,
67    /// Specialized Constant-Sum AMM for low-volatility assets (Curve-style "`StableSwap`").
68    StableSwap,
69    /// AMM with customizable token weights (e.g., Balancer style).
70    WeightedPool,
71    /// Advanced pool type that can nest other pools (Balancer V3).
72    ComposablePool,
73}
74
75/// Represents different types of decentralized exchanges (DEXes) supported by Nautilus.
76#[derive(
77    Debug,
78    Clone,
79    Copy,
80    Hash,
81    PartialOrd,
82    PartialEq,
83    Ord,
84    Eq,
85    Display,
86    EnumIter,
87    EnumString,
88    Serialize,
89    Deserialize,
90)]
91#[cfg_attr(
92    feature = "python",
93    pyo3::pyclass(
94        frozen,
95        eq,
96        eq_int,
97        module = "nautilus_trader.model",
98        from_py_object,
99        rename_all = "SCREAMING_SNAKE_CASE",
100    )
101)]
102#[cfg_attr(feature = "python", pyo3_stub_gen::derive::gen_stub_pyclass_enum)]
103pub enum DexType {
104    AerodromeSlipstream,
105    AerodromeV1,
106    BalancerV2,
107    BalancerV3,
108    BaseSwapV2,
109    BaseX,
110    CamelotV3,
111    CurveFinance,
112    FluidDEX,
113    MaverickV1,
114    MaverickV2,
115    PancakeSwapV3,
116    SushiSwapV2,
117    SushiSwapV3,
118    UniswapV2,
119    UniswapV3,
120    UniswapV4,
121}
122
123impl DexType {
124    /// Returns a reference to the `DexType` corresponding to the given dex name, or `None` if it is not found.
125    #[must_use]
126    pub fn from_dex_name(dex_name: &str) -> Option<Self> {
127        Self::from_str(dex_name).ok()
128    }
129}
130
131/// Represents a decentralized exchange (DEX) in a blockchain ecosystem.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[cfg_attr(
134    feature = "python",
135    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
136)]
137#[cfg_attr(feature = "python", pyo3_stub_gen::derive::gen_stub_pyclass)]
138pub struct Dex {
139    /// The blockchain network where this DEX operates.
140    pub chain: Chain,
141    /// The variant of the DEX protocol.
142    pub name: DexType,
143    /// The blockchain address of the DEX factory contract.
144    pub factory: Address,
145    /// The block number at which the DEX factory contract was deployed.
146    pub factory_creation_block: u64,
147    /// The event signature or identifier used to detect pool creation events.
148    pub pool_created_event: Cow<'static, str>,
149    // Optional Initialize event signature emitted when pool is initialized.
150    pub initialize_event: Option<Cow<'static, str>>,
151    /// The event signature or identifier used to detect swap events.
152    pub swap_created_event: Cow<'static, str>,
153    /// The event signature or identifier used to detect mint events.
154    pub mint_created_event: Cow<'static, str>,
155    /// The event signature or identifier used to detect burn events.
156    pub burn_created_event: Cow<'static, str>,
157    /// The event signature or identifier used to detect collect fee events.
158    pub collect_created_event: Cow<'static, str>,
159    // Optional Flash event signature emitted when flash loan occurs.
160    pub flash_created_event: Option<Cow<'static, str>>,
161    // Optional SetFeeProtocol event signature emitted when the protocol-fee config changes.
162    pub fee_protocol_update_event: Option<Cow<'static, str>>,
163    // Optional CollectProtocol event signature emitted when protocol fees are withdrawn.
164    pub fee_protocol_collect_event: Option<Cow<'static, str>>,
165    /// The type of automated market maker (AMM) algorithm used by this DEX.
166    pub amm_type: AmmType,
167    /// Collection of liquidity pools managed by this DEX.
168    #[allow(dead_code)]
169    pairs: Vec<Pool>,
170}
171
172/// A thread-safe shared pointer to a `Dex`, enabling efficient reuse across multiple components.
173pub type SharedDex = Arc<Dex>;
174
175impl Dex {
176    /// Creates a new [`Dex`] instance with the specified properties.
177    ///
178    /// # Panics
179    ///
180    /// Panics if the provided factory address is invalid.
181    #[must_use]
182    #[expect(clippy::too_many_arguments)]
183    pub fn new(
184        chain: Chain,
185        name: DexType,
186        factory: &str,
187        factory_creation_block: u64,
188        amm_type: AmmType,
189        pool_created_event: &str,
190        swap_event: &str,
191        mint_event: &str,
192        burn_event: &str,
193        collect_event: &str,
194    ) -> Self {
195        let encoded_pool_created_event =
196            hex::encode_prefixed(keccak256(pool_created_event.as_bytes()));
197        let encoded_swap_event = hex::encode_prefixed(keccak256(swap_event.as_bytes()));
198        let encoded_mint_event = hex::encode_prefixed(keccak256(mint_event.as_bytes()));
199        let encoded_burn_event = hex::encode_prefixed(keccak256(burn_event.as_bytes()));
200        let encoded_collect_event = hex::encode_prefixed(keccak256(collect_event.as_bytes()));
201        let factory_address = match validate_address(factory) {
202            Ok(address) => address,
203            Err(e) => panic!(
204                "Invalid factory address for DEX {name} on chain {chain} for factory address {factory}: {e}"
205            ),
206        };
207        Self {
208            chain,
209            name,
210            factory: factory_address,
211            factory_creation_block,
212            pool_created_event: encoded_pool_created_event.into(),
213            initialize_event: None,
214            swap_created_event: encoded_swap_event.into(),
215            mint_created_event: encoded_mint_event.into(),
216            burn_created_event: encoded_burn_event.into(),
217            collect_created_event: encoded_collect_event.into(),
218            flash_created_event: None,
219            fee_protocol_update_event: None,
220            fee_protocol_collect_event: None,
221            amm_type,
222            pairs: vec![],
223        }
224    }
225
226    /// Returns a unique identifier for this DEX, combining chain and protocol name.
227    #[must_use]
228    pub fn id(&self) -> String {
229        format!("{}:{}", self.chain.name, self.name)
230    }
231
232    /// Sets the pool initialization event signature by hashing and encoding the provided event string.
233    pub fn set_initialize_event(&mut self, event: &str) {
234        self.initialize_event = Some(hex::encode_prefixed(keccak256(event.as_bytes())).into());
235    }
236
237    /// Sets the flash loan event signature by hashing and encoding the provided event string.
238    pub fn set_flash_event(&mut self, event: &str) {
239        self.flash_created_event = Some(hex::encode_prefixed(keccak256(event.as_bytes())).into());
240    }
241
242    /// Sets the protocol-fee change event signature by hashing and encoding the provided event string.
243    pub fn set_fee_protocol_update_event(&mut self, event: &str) {
244        self.fee_protocol_update_event =
245            Some(hex::encode_prefixed(keccak256(event.as_bytes())).into());
246    }
247
248    /// Sets the protocol-fee withdrawal event signature by hashing and encoding the provided event string.
249    pub fn set_fee_protocol_collect_event(&mut self, event: &str) {
250        self.fee_protocol_collect_event =
251            Some(hex::encode_prefixed(keccak256(event.as_bytes())).into());
252    }
253}
254
255impl Display for Dex {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        write!(f, "Dex(chain={}, name={})", self.chain, self.name)
258    }
259}
260
261impl TryFrom<&Pool> for CurrencyPair {
262    type Error = CorrectnessError;
263
264    fn try_from(p: &Pool) -> Result<Self, Self::Error> {
265        let base_token = p.get_base_token();
266        let quote_token = p.get_quote_token();
267        let size_precision = base_token.decimals.min(FIXED_PRECISION);
268        let price_precision = quote_token.decimals.min(FIXED_PRECISION);
269
270        let price_increment =
271            Price::from_mantissa_exponent(1, -price_precision.cast_signed(), price_precision);
272        let size_increment =
273            Quantity::from_mantissa_exponent(1, -size_precision.cast_signed(), size_precision);
274        let base_currency = Currency::new_checked(
275            base_token.symbol.as_str(),
276            size_precision,
277            0,
278            base_token.name.as_str(),
279            CurrencyType::Crypto,
280        )?;
281        let quote_currency = Currency::new_checked(
282            quote_token.symbol.as_str(),
283            price_precision,
284            0,
285            quote_token.name.as_str(),
286            CurrencyType::Crypto,
287        )?;
288        let pair = Self::builder()
289            .instrument_id(p.instrument_id)
290            .raw_symbol(p.instrument_id.symbol)
291            .base_currency(base_currency)
292            .quote_currency(quote_currency)
293            .price_precision(price_precision)
294            .size_precision(size_precision)
295            .price_increment(price_increment)
296            .size_increment(size_increment)
297            .ts_event(p.ts_event)
298            .ts_init(p.ts_init)
299            .build()?;
300
301        for currency in [base_currency, quote_currency] {
302            if let Err(e) = Currency::register(currency, false) {
303                log::error!(
304                    "Failed to register DeFi token currency '{}': {e}",
305                    currency.code
306                );
307            }
308        }
309
310        Ok(pair)
311    }
312}
313
314impl From<Pool> for CurrencyPair {
315    fn from(p: Pool) -> Self {
316        Self::try_from(&p).expect_display(FAILED)
317    }
318}
319
320impl From<Pool> for InstrumentAny {
321    fn from(p: Pool) -> Self {
322        CurrencyPair::from(p).into_any()
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use nautilus_core::correctness::CorrectnessError;
329    use rstest::rstest;
330
331    use super::{CurrencyPair, DexType};
332    use crate::{
333        defi::{SharedPool, stubs::rain_pool},
334        enums::CurrencyType,
335        types::{Price, Quantity, currency::Currency, fixed::FIXED_PRECISION},
336    };
337
338    #[rstest]
339    fn test_dex_type_from_dex_name_valid() {
340        // Test some known DEX names
341        assert!(DexType::from_dex_name("UniswapV3").is_some());
342        assert!(DexType::from_dex_name("SushiSwapV2").is_some());
343        assert!(DexType::from_dex_name("BalancerV2").is_some());
344        assert!(DexType::from_dex_name("CamelotV3").is_some());
345
346        // Verify specific DEX type
347        let uniswap_v3 = DexType::from_dex_name("UniswapV3").unwrap();
348        assert_eq!(uniswap_v3, DexType::UniswapV3);
349
350        // Verify compound names
351        let aerodrome_slipstream = DexType::from_dex_name("AerodromeSlipstream").unwrap();
352        assert_eq!(aerodrome_slipstream, DexType::AerodromeSlipstream);
353
354        // Verify specialized names
355        let fluid_dex = DexType::from_dex_name("FluidDEX").unwrap();
356        assert_eq!(fluid_dex, DexType::FluidDEX);
357    }
358
359    #[rstest]
360    fn test_dex_type_from_dex_name_invalid() {
361        // Test unknown DEX names
362        assert!(DexType::from_dex_name("InvalidDEX").is_none());
363        assert!(DexType::from_dex_name("").is_none());
364        assert!(DexType::from_dex_name("NonExistentDEX").is_none());
365    }
366
367    #[rstest]
368    fn test_dex_type_from_dex_name_case_sensitive() {
369        // Test case sensitivity - should be case sensitive
370        assert!(DexType::from_dex_name("UniswapV3").is_some());
371        assert!(DexType::from_dex_name("uniswapv3").is_none()); // lowercase
372        assert!(DexType::from_dex_name("UNISWAPV3").is_none()); // uppercase
373        assert!(DexType::from_dex_name("UniSwapV3").is_none()); // mixed case
374
375        assert!(DexType::from_dex_name("SushiSwapV2").is_some());
376        assert!(DexType::from_dex_name("sushiswapv2").is_none()); // lowercase
377    }
378
379    #[rstest]
380    fn test_dex_type_all_variants_mappable() {
381        // Test that all DEX variants can be mapped from their string representation
382        let all_dex_names = vec![
383            "AerodromeSlipstream",
384            "AerodromeV1",
385            "BalancerV2",
386            "BalancerV3",
387            "BaseSwapV2",
388            "BaseX",
389            "CamelotV3",
390            "CurveFinance",
391            "FluidDEX",
392            "MaverickV1",
393            "MaverickV2",
394            "PancakeSwapV3",
395            "SushiSwapV2",
396            "SushiSwapV3",
397            "UniswapV2",
398            "UniswapV3",
399            "UniswapV4",
400        ];
401
402        for dex_name in all_dex_names {
403            assert!(
404                DexType::from_dex_name(dex_name).is_some(),
405                "DEX name '{dex_name}' should be valid but was not found",
406            );
407        }
408    }
409
410    #[rstest]
411    fn test_dex_type_display() {
412        // Test that DexType variants display correctly (using strum::Display)
413        assert_eq!(DexType::UniswapV3.to_string(), "UniswapV3");
414        assert_eq!(DexType::SushiSwapV2.to_string(), "SushiSwapV2");
415        assert_eq!(
416            DexType::AerodromeSlipstream.to_string(),
417            "AerodromeSlipstream"
418        );
419        assert_eq!(DexType::FluidDEX.to_string(), "FluidDEX");
420    }
421
422    #[rstest]
423    #[case(0, 6, 0, 6)]
424    #[case(6, FIXED_PRECISION, 6, FIXED_PRECISION)]
425    #[case(FIXED_PRECISION, 0, FIXED_PRECISION, 0)]
426    #[case(
427        FIXED_PRECISION + 1,
428        FIXED_PRECISION + 2,
429        FIXED_PRECISION,
430        FIXED_PRECISION
431    )]
432    fn test_pool_to_currency_pair_constructs_exact_increments(
433        #[case] size_precision: u8,
434        #[case] price_precision: u8,
435        #[case] expected_size_precision: u8,
436        #[case] expected_price_precision: u8,
437        rain_pool: SharedPool,
438    ) {
439        let mut pool = (*rain_pool).clone();
440        pool.token0.symbol = "BTC".to_string();
441        pool.token1.symbol = "USDC".to_string();
442        pool.token0.decimals = size_precision;
443        pool.token1.decimals = price_precision;
444
445        let expected_id = pool.instrument_id;
446        let expected_ts_event = pool.ts_event;
447        let expected_ts_init = pool.ts_init;
448        let pair = CurrencyPair::from(pool);
449        let price_scale_exponent = u32::from(FIXED_PRECISION - expected_price_precision);
450        let size_scale_exponent = u32::from(FIXED_PRECISION - expected_size_precision);
451
452        assert_eq!(pair.id, expected_id);
453        assert_eq!(pair.raw_symbol, expected_id.symbol);
454        assert_eq!(pair.base_currency.code, "BTC");
455        assert_eq!(pair.base_currency.precision, expected_size_precision);
456        assert_eq!(pair.quote_currency.code, "USDC");
457        assert_eq!(pair.quote_currency.precision, expected_price_precision);
458        assert_eq!(pair.price_precision, expected_price_precision);
459        assert_eq!(pair.size_precision, expected_size_precision);
460        assert_eq!(
461            pair.price_increment.raw(),
462            10_i128.pow(price_scale_exponent)
463        );
464        assert_eq!(pair.price_increment.precision, expected_price_precision);
465        assert_eq!(pair.size_increment.raw(), 10_u128.pow(size_scale_exponent));
466        assert_eq!(pair.size_increment.precision, expected_size_precision);
467        assert_eq!(pair.ts_event, expected_ts_event);
468        assert_eq!(pair.ts_init, expected_ts_init);
469    }
470
471    #[rstest]
472    fn test_pool_to_currency_pair_orients_inverted_pool_by_token_priority(rain_pool: SharedPool) {
473        let mut pool = (*rain_pool).clone();
474        pool.token0.symbol = "USDC".to_string();
475        pool.token0.name = "USD Coin".to_string();
476        pool.token0.decimals = 6;
477        pool.token1.symbol = "WETH".to_string();
478        pool.token1.name = "Wrapped Ether".to_string();
479        pool.token1.decimals = 8;
480        assert!(pool.is_base_quote_inverted());
481
482        let pair = CurrencyPair::from(pool);
483
484        assert_eq!(pair.base_currency.code, "WETH");
485        assert_eq!(pair.base_currency.name, "Wrapped Ether");
486        assert_eq!(pair.base_currency.precision, 8);
487        assert_eq!(pair.quote_currency.code, "USDC");
488        assert_eq!(pair.quote_currency.name, "USD Coin");
489        assert_eq!(pair.quote_currency.precision, 6);
490        assert_eq!(pair.size_precision, 8);
491        assert_eq!(pair.price_precision, 6);
492        assert_eq!(pair.size_increment, Quantity::from("0.00000001"));
493        assert_eq!(pair.price_increment, Price::from("0.000001"));
494    }
495
496    #[rstest]
497    fn test_pool_to_currency_pair_registers_token_currencies(rain_pool: SharedPool) {
498        let mut pool = (*rain_pool).clone();
499        pool.token0.symbol = "ENG444BASE".to_string();
500        pool.token0.name = "ENG-444 Base Token".to_string();
501        pool.token0.decimals = 8;
502        pool.token1.symbol = "ENG444QUOTE".to_string();
503        pool.token1.name = "ENG-444 Quote Token".to_string();
504        pool.token1.decimals = 6;
505
506        let _ = CurrencyPair::from(pool);
507
508        let base = Currency::try_from_str("ENG444BASE").unwrap();
509        let quote = Currency::try_from_str("ENG444QUOTE").unwrap();
510        assert_eq!(base.code, "ENG444BASE");
511        assert_eq!(base.precision, 8);
512        assert_eq!(base.iso4217, 0);
513        assert_eq!(base.name, "ENG-444 Base Token");
514        assert_eq!(base.currency_type, CurrencyType::Crypto);
515        assert_eq!(quote.code, "ENG444QUOTE");
516        assert_eq!(quote.precision, 6);
517        assert_eq!(quote.iso4217, 0);
518        assert_eq!(quote.name, "ENG-444 Quote Token");
519        assert_eq!(quote.currency_type, CurrencyType::Crypto);
520    }
521
522    #[rstest]
523    fn test_pool_to_currency_pair_rejects_invalid_token_metadata(rain_pool: SharedPool) {
524        let mut missing_symbol = (*rain_pool).clone();
525        missing_symbol.token0.symbol.clear();
526        let mut blank_symbol = (*rain_pool).clone();
527        blank_symbol.token0.symbol = "  ".to_string();
528        let mut missing_name = (*rain_pool).clone();
529        missing_name.token0.name.clear();
530
531        let missing_symbol_result = CurrencyPair::try_from(&missing_symbol);
532        let blank_symbol_result = CurrencyPair::try_from(&blank_symbol);
533        let missing_name_result = CurrencyPair::try_from(&missing_name);
534
535        assert_eq!(
536            missing_symbol_result.unwrap_err(),
537            CorrectnessError::EmptyString {
538                param: "code".to_string(),
539            }
540        );
541        assert_eq!(
542            blank_symbol_result.unwrap_err(),
543            CorrectnessError::WhitespaceString {
544                param: "code".to_string(),
545            }
546        );
547        assert_eq!(
548            missing_name_result.unwrap_err(),
549            CorrectnessError::EmptyString {
550                param: "name".to_string(),
551            }
552        );
553    }
554}