Skip to main content

nautilus_model/defi/
amm.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//! Data types specific to automated-market-maker (AMM) protocols.
17
18use std::{fmt::Display, sync::Arc};
19
20use alloy_primitives::{Address, U160};
21use nautilus_core::UnixNanos;
22use serde::{Deserialize, Serialize};
23
24use crate::{
25    data::HasTsInit,
26    defi::{
27        Blockchain, PoolIdentifier, SharedDex, chain::SharedChain, dex::Dex,
28        tick_map::tick_math::get_tick_at_sqrt_ratio, token::Token,
29    },
30    identifiers::{InstrumentId, Symbol, Venue},
31};
32
33/// Represents a liquidity pool in a decentralized exchange.
34///
35/// ## Pool Identification Architecture
36///
37/// Pools are identified differently depending on the DEX protocol version:
38///
39/// **UniswapV2/V3**: Each pool has its own smart contract deployed at a unique address.
40/// - `address` = pool contract address
41/// - `pool_identifier` = same as address (hex string)
42///
43/// **`UniswapV4`**: All pools share a singleton `PoolManager` contract. Pools are distinguished
44/// by a unique Pool ID (keccak256 hash of currencies, fee, tick spacing, and hooks).
45/// - `address` = `PoolManager` contract address (shared by all pools)
46/// - `pool_identifier` = Pool ID (bytes32 as hex string)
47///
48/// ## Instrument ID Format
49///
50/// The instrument ID encodes with the following components:
51/// - `symbol` - The pool identifier (address for V2/V3, Pool ID for V4)
52/// - `venue`  - The chain name plus DEX ID
53///
54/// String representation: `<POOL_IDENTIFIER>.<CHAIN_NAME>:<DEX_ID>`
55///
56/// Example: `0x11b815efB8f581194ae79006d24E0d814B7697F6.Ethereum:UniswapV3`
57#[cfg_attr(
58    feature = "python",
59    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
60)]
61#[cfg_attr(
62    feature = "python",
63    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
64)]
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct Pool {
67    /// The blockchain network where this pool exists.
68    pub chain: SharedChain,
69    /// The decentralized exchange protocol that created and manages this pool.
70    pub dex: SharedDex,
71    /// The blockchain address where the pool smart contract code is deployed.
72    pub address: Address,
73    /// The unique identifier for this pool across all pools on the DEX.
74    pub pool_identifier: PoolIdentifier,
75    /// The instrument ID for the pool.
76    pub instrument_id: InstrumentId,
77    /// The block number when this pool was created on the blockchain.
78    pub creation_block: u64,
79    /// The first token in the trading pair.
80    pub token0: Token,
81    /// The second token in the trading pair.
82    pub token1: Token,
83    /// The trading fee tier used by the pool expressed in hundred-thousandths
84    /// (1e-6) of one unit - identical to Uniswap-V3's fee representation.
85    ///
86    /// Examples:
87    /// • `500`   →  0.05 %  (5 bps)
88    /// • `3_000` →  0.30 %  (30 bps)
89    /// • `10_000`→  1.00 %
90    pub fee: Option<u32>,
91    /// The minimum tick spacing for positions in concentrated liquidity AMMs.
92    pub tick_spacing: Option<u32>,
93    /// The initial tick when the pool was first initialized.
94    pub initial_tick: Option<i32>,
95    /// The initial square root price when the pool was first initialized.
96    pub initial_sqrt_price_x96: Option<U160>,
97    /// The hooks contract address for Uniswap V4 pools.
98    /// For V2/V3 pools, this will be None. For V4, it contains the hooks contract address.
99    pub hooks: Option<Address>,
100    /// UNIX timestamp (nanoseconds) when the pool event occurred.
101    #[serde(default)]
102    pub ts_event: UnixNanos,
103    /// UNIX timestamp (nanoseconds) when the instance was created.
104    pub ts_init: UnixNanos,
105}
106
107/// A thread-safe shared pointer to a `Pool`, enabling efficient reuse across multiple components.
108pub type SharedPool = Arc<Pool>;
109
110impl Pool {
111    /// Creates a new [`Pool`] instance with the specified properties.
112    #[must_use]
113    #[expect(clippy::too_many_arguments)]
114    pub fn new(
115        chain: SharedChain,
116        dex: SharedDex,
117        address: Address,
118        pool_identifier: PoolIdentifier,
119        creation_block: u64,
120        token0: Token,
121        token1: Token,
122        fee: Option<u32>,
123        tick_spacing: Option<u32>,
124        ts_init: UnixNanos,
125    ) -> Self {
126        let instrument_id = Self::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
127
128        Self {
129            chain,
130            dex,
131            address,
132            pool_identifier,
133            instrument_id,
134            creation_block,
135            token0,
136            token1,
137            fee,
138            tick_spacing,
139            initial_tick: None,
140            initial_sqrt_price_x96: None,
141            hooks: None,
142            ts_event: ts_init,
143            ts_init,
144        }
145    }
146
147    /// Returns a formatted string representation of the pool for display purposes.
148    #[must_use]
149    pub fn to_full_spec_string(&self) -> String {
150        format!(
151            "{}/{}-{}.{}",
152            self.token0.symbol,
153            self.token1.symbol,
154            self.fee.unwrap_or(0),
155            self.instrument_id.venue
156        )
157    }
158
159    /// Initializes the pool with the initial tick and square root price.
160    ///
161    /// This method should be called when an Initialize event is processed
162    /// to set the initial price and tick values for the pool.
163    ///
164    /// # Panics
165    ///
166    /// Panics if the provided tick does not match the tick calculated from `sqrt_price_x96`.
167    pub fn initialize(&mut self, sqrt_price_x96: U160, tick: i32) {
168        let calculated_tick = get_tick_at_sqrt_ratio(sqrt_price_x96);
169
170        assert_eq!(
171            tick, calculated_tick,
172            "Provided tick {tick} does not match calculated tick {calculated_tick} for sqrt_price_x96 {sqrt_price_x96}",
173        );
174
175        self.initial_sqrt_price_x96 = Some(sqrt_price_x96);
176        self.initial_tick = Some(tick);
177    }
178
179    /// Sets the hooks contract address for this pool.
180    ///
181    /// This is typically called for Uniswap V4 pools that have hooks enabled.
182    pub fn set_hooks(&mut self, hooks: Address) {
183        self.hooks = Some(hooks);
184    }
185
186    #[must_use]
187    pub fn create_instrument_id(
188        chain: Blockchain,
189        dex: &Dex,
190        pool_identifier: &str,
191    ) -> InstrumentId {
192        let symbol = Symbol::new(pool_identifier);
193        let venue = Venue::new(format!("{}:{}", chain, dex.name));
194        InstrumentId::new(symbol, venue)
195    }
196
197    /// Returns the base token based on token priority.
198    ///
199    /// The base token is the asset being traded/priced. Token priority determines
200    /// which token becomes base vs quote:
201    /// - Lower priority number (1=stablecoin, 2=native, 3=other) = quote token
202    /// - Higher priority number = base token
203    #[must_use]
204    pub fn get_base_token(&self) -> &Token {
205        let priority0 = self.token0.get_token_priority();
206        let priority1 = self.token1.get_token_priority();
207
208        if priority0 < priority1 {
209            &self.token1
210        } else {
211            &self.token0
212        }
213    }
214
215    /// Returns the quote token based on token priority.
216    ///
217    /// The quote token is the pricing currency. Token priority determines
218    /// which token becomes quote:
219    /// - Lower priority number (1=stablecoin, 2=native, 3=other) = quote token
220    #[must_use]
221    pub fn get_quote_token(&self) -> &Token {
222        let priority0 = self.token0.get_token_priority();
223        let priority1 = self.token1.get_token_priority();
224
225        if priority0 < priority1 {
226            &self.token0
227        } else {
228            &self.token1
229        }
230    }
231
232    /// Returns whether the base/quote order is inverted from token0/token1 order.
233    ///
234    /// # Returns
235    /// - `true` if base=token1, quote=token0 (inverted from pool order)
236    /// - `false` if base=token0, quote=token1 (matches pool order)
237    ///
238    /// # Use Case
239    /// This is useful for knowing whether prices need to be inverted when
240    /// converting from pool convention (token1/token0) to market convention (base/quote).
241    #[must_use]
242    pub fn is_base_quote_inverted(&self) -> bool {
243        let priority0 = self.token0.get_token_priority();
244        let priority1 = self.token1.get_token_priority();
245
246        // Inverted when token0 has higher priority (becomes quote instead of base)
247        priority0 < priority1
248    }
249}
250
251impl Display for Pool {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        write!(
254            f,
255            "Pool(instrument_id={}, dex={}, fee={}, address={})",
256            self.instrument_id,
257            self.dex.name,
258            self.fee.map_or("None".to_string(), |fee| fee.to_string()),
259            self.address
260        )
261    }
262}
263
264impl HasTsInit for Pool {
265    fn ts_init(&self) -> UnixNanos {
266        self.ts_init
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use std::sync::Arc;
273
274    use rstest::rstest;
275
276    use super::*;
277    use crate::defi::{
278        chain::chains,
279        dex::{AmmType, Dex, DexType},
280        token::Token,
281    };
282
283    #[rstest]
284    fn test_pool_constructor_and_methods() {
285        let chain = Arc::new(chains::ETHEREUM.clone());
286        let dex = Dex::new(
287            chains::ETHEREUM.clone(),
288            DexType::UniswapV3,
289            "0x1F98431c8aD98523631AE4a59f267346ea31F984",
290            0,
291            AmmType::CLAMM,
292            "PoolCreated(address,address,uint24,int24,address)",
293            "Swap(address,address,int256,int256,uint160,uint128,int24)",
294            "Mint(address,address,int24,int24,uint128,uint256,uint256)",
295            "Burn(address,int24,int24,uint128,uint256,uint256)",
296            "Collect(address,address,int24,int24,uint128,uint128)",
297        );
298
299        let token0 = Token::new(
300            Arc::clone(&chain),
301            "0xA0b86a33E6441b936662bb6B5d1F8Fb0E2b57A5D"
302                .parse()
303                .unwrap(),
304            "Wrapped Ether".to_string(),
305            "WETH".to_string(),
306            18,
307        );
308
309        let token1 = Token::new(
310            Arc::clone(&chain),
311            "0xdAC17F958D2ee523a2206206994597C13D831ec7"
312                .parse()
313                .unwrap(),
314            "Tether USD".to_string(),
315            "USDT".to_string(),
316            6,
317        );
318
319        let pool_address: Address = "0x11b815efB8f581194ae79006d24E0d814B7697F6"
320            .parse()
321            .unwrap();
322        let pool_identifier = PoolIdentifier::from_address(pool_address);
323        let ts_init = UnixNanos::from(1_234_567_890_000_000_000u64);
324
325        let pool = Pool::new(
326            Arc::clone(&chain),
327            Arc::new(dex),
328            pool_address,
329            pool_identifier,
330            12_345_678,
331            token0,
332            token1,
333            Some(3000),
334            Some(60),
335            ts_init,
336        );
337
338        assert_eq!(pool.chain.chain_id, chain.chain_id);
339        assert_eq!(pool.dex.name, DexType::UniswapV3);
340        assert_eq!(pool.address, pool_address);
341        assert_eq!(pool.creation_block, 12_345_678);
342        assert_eq!(pool.token0.symbol, "WETH");
343        assert_eq!(pool.token1.symbol, "USDT");
344        assert_eq!(pool.fee.unwrap(), 3000);
345        assert_eq!(pool.tick_spacing.unwrap(), 60);
346        assert_eq!(pool.ts_init, ts_init);
347        assert_eq!(
348            pool.instrument_id.symbol.as_str(),
349            "0x11b815efB8f581194ae79006d24E0d814B7697F6"
350        );
351        assert_eq!(pool.instrument_id.venue.as_str(), "Ethereum:UniswapV3");
352        // We expect WETH to be a base and USDT a quote token
353        assert_eq!(pool.get_base_token().symbol, "WETH");
354        assert_eq!(pool.get_quote_token().symbol, "USDT");
355        assert!(!pool.is_base_quote_inverted());
356        assert_eq!(
357            pool.to_full_spec_string(),
358            "WETH/USDT-3000.Ethereum:UniswapV3"
359        );
360        assert_eq!(
361            pool.to_string(),
362            "Pool(instrument_id=0x11b815efB8f581194ae79006d24E0d814B7697F6.Ethereum:UniswapV3, \
363             dex=UniswapV3, fee=3000, address=0x11b815efB8f581194ae79006d24E0d814B7697F6)"
364        );
365    }
366
367    #[rstest]
368    fn test_pool_instrument_id_format() {
369        let chain = Arc::new(chains::ETHEREUM.clone());
370        let factory_address = "0x1F98431c8aD98523631AE4a59f267346ea31F984";
371
372        let dex = Dex::new(
373            chains::ETHEREUM.clone(),
374            DexType::UniswapV3,
375            factory_address,
376            0,
377            AmmType::CLAMM,
378            "PoolCreated(address,address,uint24,int24,address)",
379            "Swap(address,address,int256,int256,uint160,uint128,int24)",
380            "Mint(address,address,int24,int24,uint128,uint256,uint256)",
381            "Burn(address,int24,int24,uint128,uint256,uint256)",
382            "Collect(address,address,int24,int24,uint128,uint128)",
383        );
384
385        let token0 = Token::new(
386            Arc::clone(&chain),
387            "0xA0b86a33E6441b936662bb6B5d1F8Fb0E2b57A5D"
388                .parse()
389                .unwrap(),
390            "Wrapped Ether".to_string(),
391            "WETH".to_string(),
392            18,
393        );
394
395        let token1 = Token::new(
396            Arc::clone(&chain),
397            "0xdAC17F958D2ee523a2206206994597C13D831ec7"
398                .parse()
399                .unwrap(),
400            "Tether USD".to_string(),
401            "USDT".to_string(),
402            6,
403        );
404
405        let pool_address = "0x11b815efB8f581194ae79006d24E0d814B7697F6"
406            .parse()
407            .unwrap();
408
409        let pool = Pool::new(
410            chain,
411            Arc::new(dex),
412            pool_address,
413            PoolIdentifier::from_address(pool_address),
414            0,
415            token0,
416            token1,
417            Some(3000),
418            Some(60),
419            UnixNanos::default(),
420        );
421
422        assert_eq!(
423            pool.instrument_id.to_string(),
424            "0x11b815efB8f581194ae79006d24E0d814B7697F6.Ethereum:UniswapV3"
425        );
426    }
427}