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    #[getter]
112    #[pyo3(name = "fee_protocol0_basis_points")]
113    fn py_fee_protocol0_basis_points(&self) -> Option<u32> {
114        self.state.fee_protocol0_basis_points
115    }
116
117    #[getter]
118    #[pyo3(name = "fee_protocol1_basis_points")]
119    fn py_fee_protocol1_basis_points(&self) -> Option<u32> {
120        self.state.fee_protocol1_basis_points
121    }
122
123    /// Returns the pool's active liquidity tracked by the tick map.
124    ///
125    /// This represents the effective liquidity available for trading at the current price.
126    /// The tick map maintains this value efficiently by updating it during tick crossings
127    /// as the price moves through different ranges.
128    ///
129    /// # Returns
130    /// The active liquidity (u128) at the current tick from the tick map
131    #[pyo3(name = "get_active_liquidity")]
132    fn py_get_active_liquidity(&self) -> u128 {
133        self.get_active_liquidity()
134    }
135
136    /// Gets the number of active ticks.
137    #[pyo3(name = "get_active_tick_count")]
138    fn py_get_active_tick_count(&self) -> usize {
139        self.get_active_tick_count()
140    }
141
142    /// Gets the total number of ticks tracked by the tick map.
143    ///
144    /// Returns count of all ticks that have ever been initialized,
145    /// including those that may no longer have active liquidity.
146    ///
147    /// # Returns
148    /// Total tick count in the tick map
149    #[pyo3(name = "get_total_tick_count")]
150    fn py_get_total_tick_count(&self) -> usize {
151        self.get_total_tick_count()
152    }
153
154    /// Gets the count of positions that are currently active.
155    ///
156    /// Active positions are those with liquidity > 0 and whose tick range
157    /// includes the current pool tick (meaning they have tokens in the pool).
158    #[pyo3(name = "get_total_active_positions")]
159    fn py_get_total_active_positions(&self) -> usize {
160        self.get_total_active_positions()
161    }
162
163    /// Gets the count of positions that are currently inactive.
164    ///
165    /// Inactive positions are those that exist but don't span the current tick,
166    /// meaning their liquidity is entirely in one token or the other.
167    #[pyo3(name = "get_total_inactive_positions")]
168    fn py_get_total_inactive_positions(&self) -> usize {
169        self.get_total_inactive_positions()
170    }
171
172    /// Estimates the total amount of token0 in the pool.
173    ///
174    /// Calculates token0 balance by summing:
175    /// - Token0 amounts from all active liquidity positions
176    /// - Accumulated trading fees (approximated from fee growth)
177    /// - Protocol fees collected
178    #[pyo3(name = "estimate_balance_of_token0")]
179    fn py_estimate_balance_of_token0(&self) -> String {
180        self.estimate_balance_of_token0().to_string()
181    }
182
183    /// Estimates the total amount of token1 in the pool.
184    ///
185    /// Calculates token1 balance by summing:
186    /// - Token1 amounts from all active liquidity positions
187    /// - Accumulated trading fees (approximated from fee growth)
188    /// - Protocol fees collected
189    #[pyo3(name = "estimate_balance_of_token1")]
190    fn py_estimate_balance_of_token1(&self) -> String {
191        self.estimate_balance_of_token1().to_string()
192    }
193
194    #[pyo3(name = "get_total_liquidity")]
195    fn py_get_total_liquidity_all_positions(&self) -> String {
196        self.get_total_liquidity().to_string()
197    }
198
199    /// Calculates the liquidity utilization rate for the pool.
200    ///
201    /// The utilization rate measures what percentage of total deployed liquidity
202    /// is currently active (in-range and earning fees) at the current price tick.
203    #[pyo3(name = "liquidity_utilization_rate")]
204    fn py_liquidity_utilization_rate(&self) -> f64 {
205        self.liquidity_utilization_rate()
206    }
207
208    /// Simulates an exact input swap (know input amount, calculate output amount).
209    ///
210    /// # Errors
211    /// Returns error if pool is not initialized, input is zero, or price limit is invalid
212    #[pyo3(name = "swap_exact_in")]
213    fn py_swap_exact_in(
214        &self,
215        amount_in: &str,
216        zero_for_one: bool,
217        sqrt_price_limit_x96: Option<&str>,
218    ) -> PyResult<SwapQuote> {
219        let amount_in = U256::from_str(amount_in).map_err(to_pyvalue_err)?;
220        let sqrt_price_limit = match sqrt_price_limit_x96 {
221            Some(limit_str) => Some(U160::from_str(limit_str).map_err(to_pyvalue_err)?),
222            None => None,
223        };
224
225        self.swap_exact_in(amount_in, zero_for_one, sqrt_price_limit)
226            .map_err(to_pyvalue_err)
227    }
228
229    /// Simulates an exact output swap (know output amount, calculate required input amount).
230    ///
231    /// # Errors
232    /// Returns error if pool is not initialized, output is zero, price limit is invalid,
233    /// or insufficient liquidity exists to fulfill the exact output amount
234    #[pyo3(name = "swap_exact_out")]
235    fn py_swap_exact_out(
236        &self,
237        amount_out: &str,
238        zero_for_one: bool,
239        sqrt_price_limit_x96: Option<&str>,
240    ) -> PyResult<SwapQuote> {
241        let amount_out = U256::from_str(amount_out).map_err(to_pyvalue_err)?;
242        let sqrt_price_limit = match sqrt_price_limit_x96 {
243            Some(limit_str) => Some(U160::from_str(limit_str).map_err(to_pyvalue_err)?),
244            None => None,
245        };
246
247        self.swap_exact_out(amount_out, zero_for_one, sqrt_price_limit)
248            .map_err(to_pyvalue_err)
249    }
250
251    /// Finds the maximum trade size that produces a target slippage (including fees).
252    ///
253    /// Uses binary search to find the largest trade size that results in slippage
254    /// at or below the target. The method iteratively simulates swaps at different
255    /// sizes until it converges to the optimal size within the specified tolerance.
256    ///
257    /// # Returns
258    /// The maximum trade size (U256) that produces the target slippage
259    ///
260    /// # Errors
261    /// Returns error if:
262    /// - Impact is zero or exceeds 100% (10000 bps)
263    /// - Pool is not initialized
264    /// - Swap simulations fail
265    #[pyo3(name = "size_for_impact_bps")]
266    fn py_size_for_impact_bps(&self, impact_bps: u32, zero_for_one: bool) -> PyResult<String> {
267        self.size_for_impact_bps(impact_bps, zero_for_one)
268            .map(|size| size.to_string())
269            .map_err(to_pyvalue_err)
270    }
271
272    /// Finds the maximum trade size with search diagnostics.
273    /// This is the detailed version of `Self.size_for_impact_bps` that returns
274    /// extensive information about the search process.It is useful for debugging,
275    /// monitoring, and analyzing search behavior in production.
276    ///
277    /// # Returns
278    /// Detailed result with size and search diagnostics
279    ///
280    /// # Errors
281    /// Returns error if:
282    /// - Impact is zero or exceeds 100% (10000 bps)
283    /// - Pool is not initialized
284    /// - Swap simulations fail
285    #[pyo3(name = "size_for_impact_bps_detailed")]
286    fn py_size_for_impact_bps_detailed(
287        &self,
288        impact_bps: u32,
289        zero_for_one: bool,
290    ) -> PyResult<SizeForImpactResult> {
291        self.size_for_impact_bps_detailed(impact_bps, zero_for_one)
292            .map_err(to_pyvalue_err)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use std::{str::FromStr, sync::Arc};
299
300    use alloy_primitives::{U160, address};
301    use nautilus_core::UnixNanos;
302    use pyo3::{
303        Python,
304        types::{PyAnyMethods, PyInt},
305    };
306    use rstest::rstest;
307
308    use crate::defi::{
309        AmmType, Blockchain, Chain, Dex, DexType, Pool, PoolIdentifier, Token,
310        pool_analysis::PoolProfiler,
311    };
312
313    #[rstest]
314    fn price_sqrt_ratio_x96_returns_python_int() {
315        let sqrt_price_x96 = U160::from_str("79228162514264337593543950336").unwrap();
316        let mut profiler = PoolProfiler::new(pool());
317        profiler.initialize(sqrt_price_x96).unwrap();
318        Python::initialize();
319
320        Python::attach(|py| {
321            let value = profiler.py_price_sqrt_ratio_x96(py).unwrap();
322            let value = value.bind(py);
323
324            assert!(value.is_instance_of::<PyInt>());
325            assert_eq!(value.str().unwrap().to_string(), sqrt_price_x96.to_string());
326        });
327    }
328
329    fn pool() -> Arc<Pool> {
330        let chain = Arc::new(Chain::new(Blockchain::Ethereum, 1));
331        let dex = Arc::new(Dex::new(
332            (*chain).clone(),
333            DexType::UniswapV3,
334            "0x0000000000000000000000000000000000000fac",
335            1,
336            AmmType::CLAMM,
337            "PoolCreated",
338            "Swap",
339            "Mint",
340            "Burn",
341            "Collect",
342        ));
343        let token0 = Token::new(
344            chain.clone(),
345            address!("0000000000000000000000000000000000000001"),
346            "USD Coin".to_string(),
347            "USDC".to_string(),
348            6,
349        );
350        let token1 = Token::new(
351            chain.clone(),
352            address!("0000000000000000000000000000000000000002"),
353            "Wrapped Ether".to_string(),
354            "WETH".to_string(),
355            18,
356        );
357        let pool_address = address!("0000000000000000000000000000000000000003");
358
359        Arc::new(Pool::new(
360            chain,
361            dex,
362            pool_address,
363            PoolIdentifier::from_address(pool_address),
364            1,
365            token0,
366            token1,
367            Some(500),
368            Some(10),
369            UnixNanos::default(),
370        ))
371    }
372}