nautilus_model/defi/
amm.rs

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