Skip to main content

nautilus_model/python/defi/
profiler.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//! Python bindings for DeFi pool profiler.
17
18use std::str::FromStr;
19
20use alloy_primitives::{U160, U256};
21use nautilus_core::python::to_pyvalue_err;
22use pyo3::{prelude::*, types::PyModule};
23
24use crate::{
25    defi::{
26        Pool,
27        pool_analysis::{PoolProfiler, quote::SwapQuote, size_estimator::SizeForImpactResult},
28    },
29    identifiers::InstrumentId,
30};
31
32#[pymethods]
33#[pyo3_stub_gen::derive::gen_stub_pymethods]
34impl PoolProfiler {
35    #[getter]
36    #[pyo3(name = "pool")]
37    fn py_pool(&self) -> Pool {
38        self.pool.as_ref().clone()
39    }
40
41    #[getter]
42    #[pyo3(name = "instrument_id")]
43    fn py_instrument_id(&self) -> InstrumentId {
44        self.pool.instrument_id
45    }
46
47    #[getter]
48    #[pyo3(name = "is_initialized")]
49    fn py_is_initialized(&self) -> bool {
50        self.is_initialized
51    }
52
53    #[getter]
54    #[pyo3(name = "current_tick")]
55    fn py_current_tick(&self) -> i32 {
56        self.state.current_tick
57    }
58
59    #[getter]
60    #[pyo3(name = "price_sqrt_ratio_x96")]
61    #[gen_stub(override_return_type(type_repr = "int"))]
62    fn py_price_sqrt_ratio_x96(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
63        Ok(PyModule::import(py, "builtins")?
64            .getattr("int")?
65            .call1((self.state.price_sqrt_ratio_x96.to_string(),))?
66            .unbind())
67    }
68
69    #[getter]
70    #[pyo3(name = "total_amount0_deposited")]
71    fn py_total_amount0_deposited(&self) -> String {
72        self.analytics.total_amount0_deposited.to_string()
73    }
74
75    #[getter]
76    #[pyo3(name = "total_amount1_deposited")]
77    fn py_total_amount1_deposited(&self) -> String {
78        self.analytics.total_amount1_deposited.to_string()
79    }
80
81    #[getter]
82    #[pyo3(name = "total_amount0_collected")]
83    fn py_total_amount0_collected(&self) -> String {
84        self.analytics.total_amount0_collected.to_string()
85    }
86
87    #[getter]
88    #[pyo3(name = "total_amount1_collected")]
89    fn py_total_amount1_collected(&self) -> String {
90        self.analytics.total_amount1_collected.to_string()
91    }
92
93    #[getter]
94    #[pyo3(name = "protocol_fees_token0")]
95    fn py_protocol_fees_token0(&self) -> String {
96        self.state.protocol_fees_token0.to_string()
97    }
98
99    #[getter]
100    #[pyo3(name = "protocol_fees_token1")]
101    fn py_protocol_fees_token1(&self) -> String {
102        self.state.protocol_fees_token1.to_string()
103    }
104
105    #[getter]
106    #[pyo3(name = "fee_protocol")]
107    fn py_fee_protocol(&self) -> u8 {
108        self.state.fee_protocol
109    }
110
111    /// Returns the pool's active liquidity tracked by the tick map.
112    ///
113    /// This represents the effective liquidity available for trading at the current price.
114    /// The tick map maintains this value efficiently by updating it during tick crossings
115    /// as the price moves through different ranges.
116    ///
117    /// # Returns
118    /// The active liquidity (u128) at the current tick from the tick map
119    #[pyo3(name = "get_active_liquidity")]
120    fn py_get_active_liquidity(&self) -> u128 {
121        self.get_active_liquidity()
122    }
123
124    /// Gets the number of active ticks.
125    #[pyo3(name = "get_active_tick_count")]
126    fn py_get_active_tick_count(&self) -> usize {
127        self.get_active_tick_count()
128    }
129
130    /// Gets the total number of ticks tracked by the tick map.
131    ///
132    /// Returns count of all ticks that have ever been initialized,
133    /// including those that may no longer have active liquidity.
134    ///
135    /// # Returns
136    /// Total tick count in the tick map
137    #[pyo3(name = "get_total_tick_count")]
138    fn py_get_total_tick_count(&self) -> usize {
139        self.get_total_tick_count()
140    }
141
142    /// Gets the count of positions that are currently active.
143    ///
144    /// Active positions are those with liquidity > 0 and whose tick range
145    /// includes the current pool tick (meaning they have tokens in the pool).
146    #[pyo3(name = "get_total_active_positions")]
147    fn py_get_total_active_positions(&self) -> usize {
148        self.get_total_active_positions()
149    }
150
151    /// Gets the count of positions that are currently inactive.
152    ///
153    /// Inactive positions are those that exist but don't span the current tick,
154    /// meaning their liquidity is entirely in one token or the other.
155    #[pyo3(name = "get_total_inactive_positions")]
156    fn py_get_total_inactive_positions(&self) -> usize {
157        self.get_total_inactive_positions()
158    }
159
160    /// Estimates the total amount of token0 in the pool.
161    ///
162    /// Calculates token0 balance by summing:
163    /// - Token0 amounts from all active liquidity positions
164    /// - Accumulated trading fees (approximated from fee growth)
165    /// - Protocol fees collected
166    #[pyo3(name = "estimate_balance_of_token0")]
167    fn py_estimate_balance_of_token0(&self) -> String {
168        self.estimate_balance_of_token0().to_string()
169    }
170
171    /// Estimates the total amount of token1 in the pool.
172    ///
173    /// Calculates token1 balance by summing:
174    /// - Token1 amounts from all active liquidity positions
175    /// - Accumulated trading fees (approximated from fee growth)
176    /// - Protocol fees collected
177    #[pyo3(name = "estimate_balance_of_token1")]
178    fn py_estimate_balance_of_token1(&self) -> String {
179        self.estimate_balance_of_token1().to_string()
180    }
181
182    #[pyo3(name = "get_total_liquidity")]
183    fn py_get_total_liquidity_all_positions(&self) -> String {
184        self.get_total_liquidity().to_string()
185    }
186
187    /// Calculates the liquidity utilization rate for the pool.
188    ///
189    /// The utilization rate measures what percentage of total deployed liquidity
190    /// is currently active (in-range and earning fees) at the current price tick.
191    #[pyo3(name = "liquidity_utilization_rate")]
192    fn py_liquidity_utilization_rate(&self) -> f64 {
193        self.liquidity_utilization_rate()
194    }
195
196    /// Simulates an exact input swap (know input amount, calculate output amount).
197    #[pyo3(name = "swap_exact_in")]
198    fn py_swap_exact_in(
199        &self,
200        amount_in: &str,
201        zero_for_one: bool,
202        sqrt_price_limit_x96: Option<&str>,
203    ) -> PyResult<SwapQuote> {
204        let amount_in = U256::from_str(amount_in).map_err(to_pyvalue_err)?;
205        let sqrt_price_limit = match sqrt_price_limit_x96 {
206            Some(limit_str) => Some(U160::from_str(limit_str).map_err(to_pyvalue_err)?),
207            None => None,
208        };
209
210        self.swap_exact_in(amount_in, zero_for_one, sqrt_price_limit)
211            .map_err(to_pyvalue_err)
212    }
213
214    /// Simulates an exact output swap (know output amount, calculate required input amount).
215    #[pyo3(name = "swap_exact_out")]
216    fn py_swap_exact_out(
217        &self,
218        amount_out: &str,
219        zero_for_one: bool,
220        sqrt_price_limit_x96: Option<&str>,
221    ) -> PyResult<SwapQuote> {
222        let amount_out = U256::from_str(amount_out).map_err(to_pyvalue_err)?;
223        let sqrt_price_limit = match sqrt_price_limit_x96 {
224            Some(limit_str) => Some(U160::from_str(limit_str).map_err(to_pyvalue_err)?),
225            None => None,
226        };
227
228        self.swap_exact_out(amount_out, zero_for_one, sqrt_price_limit)
229            .map_err(to_pyvalue_err)
230    }
231
232    /// Finds the maximum trade size that produces a target slippage (including fees).
233    ///
234    /// Uses binary search to find the largest trade size that results in slippage
235    /// at or below the target. The method iteratively simulates swaps at different
236    /// sizes until it converges to the optimal size within the specified tolerance.
237    ///
238    /// # Returns
239    /// The maximum trade size (U256) that produces the target slippage
240    ///
241    /// # Errors
242    /// Returns error if:
243    /// - Impact is zero or exceeds 100% (10000 bps)
244    /// - Pool is not initialized
245    /// - Swap simulations fail
246    #[pyo3(name = "size_for_impact_bps")]
247    fn py_size_for_impact_bps(&self, impact_bps: u32, zero_for_one: bool) -> PyResult<String> {
248        self.size_for_impact_bps(impact_bps, zero_for_one)
249            .map(|size| size.to_string())
250            .map_err(to_pyvalue_err)
251    }
252
253    /// Finds the maximum trade size with search diagnostics.
254    /// This is the detailed version of `Self.size_for_impact_bps` that returns
255    /// extensive information about the search process.It is useful for debugging,
256    /// monitoring, and analyzing search behavior in production.
257    ///
258    /// # Returns
259    /// Detailed result with size and search diagnostics
260    #[pyo3(name = "size_for_impact_bps_detailed")]
261    fn py_size_for_impact_bps_detailed(
262        &self,
263        impact_bps: u32,
264        zero_for_one: bool,
265    ) -> PyResult<SizeForImpactResult> {
266        self.size_for_impact_bps_detailed(impact_bps, zero_for_one)
267            .map_err(to_pyvalue_err)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use std::{str::FromStr, sync::Arc};
274
275    use alloy_primitives::{U160, address};
276    use nautilus_core::UnixNanos;
277    use pyo3::{
278        Python,
279        types::{PyAnyMethods, PyInt},
280    };
281    use rstest::rstest;
282
283    use crate::defi::{
284        AmmType, Blockchain, Chain, Dex, DexType, Pool, PoolIdentifier, Token,
285        pool_analysis::PoolProfiler,
286    };
287
288    #[rstest]
289    fn price_sqrt_ratio_x96_returns_python_int() {
290        let sqrt_price_x96 = U160::from_str("79228162514264337593543950336").unwrap();
291        let mut profiler = PoolProfiler::new(pool());
292        profiler.initialize(sqrt_price_x96).unwrap();
293        Python::initialize();
294
295        Python::attach(|py| {
296            let value = profiler.py_price_sqrt_ratio_x96(py).unwrap();
297            let value = value.bind(py);
298
299            assert!(value.is_instance_of::<PyInt>());
300            assert_eq!(value.str().unwrap().to_string(), sqrt_price_x96.to_string());
301        });
302    }
303
304    fn pool() -> Arc<Pool> {
305        let chain = Arc::new(Chain::new(Blockchain::Ethereum, 1));
306        let dex = Arc::new(Dex::new(
307            (*chain).clone(),
308            DexType::UniswapV3,
309            "0x0000000000000000000000000000000000000fac",
310            1,
311            AmmType::CLAMM,
312            "PoolCreated",
313            "Swap",
314            "Mint",
315            "Burn",
316            "Collect",
317        ));
318        let token0 = Token::new(
319            chain.clone(),
320            address!("0000000000000000000000000000000000000001"),
321            "USD Coin".to_string(),
322            "USDC".to_string(),
323            6,
324        );
325        let token1 = Token::new(
326            chain.clone(),
327            address!("0000000000000000000000000000000000000002"),
328            "Wrapped Ether".to_string(),
329            "WETH".to_string(),
330            18,
331        );
332        let pool_address = address!("0000000000000000000000000000000000000003");
333
334        Arc::new(Pool::new(
335            chain,
336            dex,
337            pool_address,
338            PoolIdentifier::from_address(pool_address),
339            1,
340            token0,
341            token1,
342            Some(500),
343            Some(10),
344            UnixNanos::default(),
345        ))
346    }
347}