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