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::builder()
290            .instrument_id(p.instrument_id)
291            .raw_symbol(p.instrument_id.symbol)
292            .base_currency(base_currency)
293            .quote_currency(quote_currency)
294            .price_precision(price_precision)
295            .size_precision(size_precision)
296            .price_increment(price_increment)
297            .size_increment(size_increment)
298            .maybe_taker_fee(taker_fee)
299            .ts_event(p.ts_event)
300            .ts_init(p.ts_init)
301            .build()?;
302
303        for currency in [base_currency, quote_currency] {
304            if let Err(e) = Currency::register(currency, false) {
305                log::error!(
306                    "Failed to register DeFi token currency '{}': {e}",
307                    currency.code
308                );
309            }
310        }
311
312        Ok(pair)
313    }
314}
315
316impl From<Pool> for CurrencyPair {
317    fn from(p: Pool) -> Self {
318        Self::try_from(&p).expect_display(FAILED)
319    }
320}
321
322impl From<Pool> for InstrumentAny {
323    fn from(p: Pool) -> Self {
324        CurrencyPair::from(p).into_any()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use nautilus_core::correctness::CorrectnessError;
331    use rstest::rstest;
332    use rust_decimal::Decimal;
333
334    use super::{CurrencyPair, DexType};
335    use crate::{
336        defi::{SharedPool, stubs::rain_pool},
337        enums::CurrencyType,
338        types::{currency::Currency, fixed::FIXED_PRECISION},
339    };
340
341    #[rstest]
342    fn test_dex_type_from_dex_name_valid() {
343        // Test some known DEX names
344        assert!(DexType::from_dex_name("UniswapV3").is_some());
345        assert!(DexType::from_dex_name("SushiSwapV2").is_some());
346        assert!(DexType::from_dex_name("BalancerV2").is_some());
347        assert!(DexType::from_dex_name("CamelotV3").is_some());
348
349        // Verify specific DEX type
350        let uniswap_v3 = DexType::from_dex_name("UniswapV3").unwrap();
351        assert_eq!(uniswap_v3, DexType::UniswapV3);
352
353        // Verify compound names
354        let aerodrome_slipstream = DexType::from_dex_name("AerodromeSlipstream").unwrap();
355        assert_eq!(aerodrome_slipstream, DexType::AerodromeSlipstream);
356
357        // Verify specialized names
358        let fluid_dex = DexType::from_dex_name("FluidDEX").unwrap();
359        assert_eq!(fluid_dex, DexType::FluidDEX);
360    }
361
362    #[rstest]
363    fn test_dex_type_from_dex_name_invalid() {
364        // Test unknown DEX names
365        assert!(DexType::from_dex_name("InvalidDEX").is_none());
366        assert!(DexType::from_dex_name("").is_none());
367        assert!(DexType::from_dex_name("NonExistentDEX").is_none());
368    }
369
370    #[rstest]
371    fn test_dex_type_from_dex_name_case_sensitive() {
372        // Test case sensitivity - should be case sensitive
373        assert!(DexType::from_dex_name("UniswapV3").is_some());
374        assert!(DexType::from_dex_name("uniswapv3").is_none()); // lowercase
375        assert!(DexType::from_dex_name("UNISWAPV3").is_none()); // uppercase
376        assert!(DexType::from_dex_name("UniSwapV3").is_none()); // mixed case
377
378        assert!(DexType::from_dex_name("SushiSwapV2").is_some());
379        assert!(DexType::from_dex_name("sushiswapv2").is_none()); // lowercase
380    }
381
382    #[rstest]
383    fn test_dex_type_all_variants_mappable() {
384        // Test that all DEX variants can be mapped from their string representation
385        let all_dex_names = vec![
386            "AerodromeSlipstream",
387            "AerodromeV1",
388            "BalancerV2",
389            "BalancerV3",
390            "BaseSwapV2",
391            "BaseX",
392            "CamelotV3",
393            "CurveFinance",
394            "FluidDEX",
395            "MaverickV1",
396            "MaverickV2",
397            "PancakeSwapV3",
398            "SushiSwapV2",
399            "SushiSwapV3",
400            "UniswapV2",
401            "UniswapV3",
402            "UniswapV4",
403        ];
404
405        for dex_name in all_dex_names {
406            assert!(
407                DexType::from_dex_name(dex_name).is_some(),
408                "DEX name '{dex_name}' should be valid but was not found",
409            );
410        }
411    }
412
413    #[rstest]
414    fn test_dex_type_display() {
415        // Test that DexType variants display correctly (using strum::Display)
416        assert_eq!(DexType::UniswapV3.to_string(), "UniswapV3");
417        assert_eq!(DexType::SushiSwapV2.to_string(), "SushiSwapV2");
418        assert_eq!(
419            DexType::AerodromeSlipstream.to_string(),
420            "AerodromeSlipstream"
421        );
422        assert_eq!(DexType::FluidDEX.to_string(), "FluidDEX");
423    }
424
425    #[rstest]
426    #[case(0, 6, 0, 6)]
427    #[case(6, FIXED_PRECISION, 6, FIXED_PRECISION)]
428    #[case(FIXED_PRECISION, 0, FIXED_PRECISION, 0)]
429    #[case(
430        FIXED_PRECISION + 1,
431        FIXED_PRECISION + 2,
432        FIXED_PRECISION,
433        FIXED_PRECISION
434    )]
435    fn test_pool_to_currency_pair_constructs_exact_increments(
436        #[case] size_precision: u8,
437        #[case] price_precision: u8,
438        #[case] expected_size_precision: u8,
439        #[case] expected_price_precision: u8,
440        rain_pool: SharedPool,
441    ) {
442        let mut pool = (*rain_pool).clone();
443        pool.token0.symbol = "BTC".to_string();
444        pool.token1.symbol = "USDC".to_string();
445        pool.token0.decimals = size_precision;
446        pool.token1.decimals = price_precision;
447
448        let expected_id = pool.instrument_id;
449        let expected_taker_fee = pool.fee.map(|fee| Decimal::new(i64::from(fee), 6));
450        let expected_ts_event = pool.ts_event;
451        let expected_ts_init = pool.ts_init;
452        let pair = CurrencyPair::from(pool);
453        let price_scale_exponent = u32::from(FIXED_PRECISION - expected_price_precision);
454        let size_scale_exponent = u32::from(FIXED_PRECISION - expected_size_precision);
455
456        assert_eq!(pair.id, expected_id);
457        assert_eq!(pair.raw_symbol, expected_id.symbol);
458        assert_eq!(pair.base_currency.code.as_str(), "BTC");
459        assert_eq!(pair.base_currency.precision, expected_size_precision);
460        assert_eq!(pair.quote_currency.code.as_str(), "USDC");
461        assert_eq!(pair.quote_currency.precision, expected_price_precision);
462        assert_eq!(pair.price_precision, expected_price_precision);
463        assert_eq!(pair.size_precision, expected_size_precision);
464        assert_eq!(pair.price_increment.raw, 10_i128.pow(price_scale_exponent));
465        assert_eq!(pair.price_increment.precision, expected_price_precision);
466        assert_eq!(pair.size_increment.raw, 10_u128.pow(size_scale_exponent));
467        assert_eq!(pair.size_increment.precision, expected_size_precision);
468        assert_eq!(pair.maker_fee, Decimal::ZERO);
469        assert_eq!(pair.taker_fee, expected_taker_fee.unwrap());
470        assert_eq!(pair.ts_event, expected_ts_event);
471        assert_eq!(pair.ts_init, expected_ts_init);
472    }
473
474    #[rstest]
475    fn test_pool_to_currency_pair_registers_token_currencies(rain_pool: SharedPool) {
476        let mut pool = (*rain_pool).clone();
477        pool.token0.symbol = "ENG444BASE".to_string();
478        pool.token0.name = "ENG-444 Base Token".to_string();
479        pool.token0.decimals = 8;
480        pool.token1.symbol = "ENG444QUOTE".to_string();
481        pool.token1.name = "ENG-444 Quote Token".to_string();
482        pool.token1.decimals = 6;
483
484        let _ = CurrencyPair::from(pool);
485
486        let base = Currency::try_from_str("ENG444BASE").unwrap();
487        let quote = Currency::try_from_str("ENG444QUOTE").unwrap();
488        assert_eq!(base.code.as_str(), "ENG444BASE");
489        assert_eq!(base.precision, 8);
490        assert_eq!(base.iso4217, 0);
491        assert_eq!(base.name.as_str(), "ENG-444 Base Token");
492        assert_eq!(base.currency_type, CurrencyType::Crypto);
493        assert_eq!(quote.code.as_str(), "ENG444QUOTE");
494        assert_eq!(quote.precision, 6);
495        assert_eq!(quote.iso4217, 0);
496        assert_eq!(quote.name.as_str(), "ENG-444 Quote Token");
497        assert_eq!(quote.currency_type, CurrencyType::Crypto);
498    }
499
500    #[rstest]
501    fn test_pool_to_currency_pair_rejects_invalid_token_metadata(rain_pool: SharedPool) {
502        let mut missing_symbol = (*rain_pool).clone();
503        missing_symbol.token0.symbol.clear();
504        let mut blank_symbol = (*rain_pool).clone();
505        blank_symbol.token0.symbol = "  ".to_string();
506        let mut missing_name = (*rain_pool).clone();
507        missing_name.token0.name.clear();
508
509        let missing_symbol_result = CurrencyPair::try_from(&missing_symbol);
510        let blank_symbol_result = CurrencyPair::try_from(&blank_symbol);
511        let missing_name_result = CurrencyPair::try_from(&missing_name);
512
513        assert_eq!(
514            missing_symbol_result.unwrap_err(),
515            CorrectnessError::EmptyString {
516                param: "code".to_string(),
517            }
518        );
519        assert_eq!(
520            blank_symbol_result.unwrap_err(),
521            CorrectnessError::WhitespaceString {
522                param: "code".to_string(),
523            }
524        );
525        assert_eq!(
526            missing_name_result.unwrap_err(),
527            CorrectnessError::EmptyString {
528                param: "name".to_string(),
529            }
530        );
531    }
532}