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 fraction of total deployed liquidity
202    /// is currently active (in-range and earning fees) at the current price tick.
203    /// Returns zero when no position liquidity is tracked, otherwise truncates to six decimal places.
204    /// Partial-history replay can produce values above one when active liquidity includes positions
205    /// whose mint events were not replayed.
206    #[pyo3(name = "liquidity_utilization_rate")]
207    fn py_liquidity_utilization_rate(&self) -> f64 {
208        self.liquidity_utilization_rate()
209    }
210
211    /// Simulates an exact input swap (know input amount, calculate output amount).
212    ///
213    /// # Errors
214    /// Returns an error if pool is not initialized, input is zero, or price limit is invalid.
215    #[pyo3(name = "swap_exact_in")]
216    fn py_swap_exact_in(
217        &self,
218        amount_in: &str,
219        zero_for_one: bool,
220        sqrt_price_limit_x96: Option<&str>,
221    ) -> PyResult<SwapQuote> {
222        let amount_in = U256::from_str(amount_in).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_in(amount_in, zero_for_one, sqrt_price_limit)
229            .map_err(to_pyvalue_err)
230    }
231
232    /// Simulates an exact output swap (know output amount, calculate required input amount).
233    ///
234    /// # Errors
235    /// Returns an error if pool is not initialized, output is zero, price limit is invalid, or
236    /// insufficient liquidity exists to fulfill the exact output amount.
237    #[pyo3(name = "swap_exact_out")]
238    fn py_swap_exact_out(
239        &self,
240        amount_out: &str,
241        zero_for_one: bool,
242        sqrt_price_limit_x96: Option<&str>,
243    ) -> PyResult<SwapQuote> {
244        let amount_out = U256::from_str(amount_out).map_err(to_pyvalue_err)?;
245        let sqrt_price_limit = match sqrt_price_limit_x96 {
246            Some(limit_str) => Some(U160::from_str(limit_str).map_err(to_pyvalue_err)?),
247            None => None,
248        };
249
250        self.swap_exact_out(amount_out, zero_for_one, sqrt_price_limit)
251            .map_err(to_pyvalue_err)
252    }
253
254    /// Finds the maximum trade size that produces a target slippage (including fees).
255    ///
256    /// Uses binary search to find the largest trade size that results in slippage
257    /// at or below the target. The method iteratively simulates swaps at different
258    /// sizes until it converges to the optimal size within the specified tolerance.
259    ///
260    /// # Returns
261    /// The maximum trade size (U256) that produces the target slippage
262    ///
263    /// # Errors
264    /// Returns an error if:
265    /// - Impact is zero or exceeds 100% (10000 bps).
266    /// - Pool is not initialized.
267    /// - Swap simulations fail.
268    #[pyo3(name = "size_for_impact_bps")]
269    fn py_size_for_impact_bps(&self, impact_bps: u32, zero_for_one: bool) -> PyResult<String> {
270        self.size_for_impact_bps(impact_bps, zero_for_one)
271            .map(|size| size.to_string())
272            .map_err(to_pyvalue_err)
273    }
274
275    /// Finds the maximum trade size with search diagnostics.
276    /// This is the detailed version of `Self.size_for_impact_bps` that returns
277    /// extensive information about the search process.It is useful for debugging,
278    /// monitoring, and analyzing search behavior in production.
279    ///
280    /// # Returns
281    /// Detailed result with size and search diagnostics
282    ///
283    /// # Errors
284    /// Returns an error if:
285    /// - Impact is zero or exceeds 100% (10000 bps).
286    /// - Pool is not initialized.
287    /// - Swap simulations fail.
288    #[pyo3(name = "size_for_impact_bps_detailed")]
289    fn py_size_for_impact_bps_detailed(
290        &self,
291        impact_bps: u32,
292        zero_for_one: bool,
293    ) -> PyResult<SizeForImpactResult> {
294        self.size_for_impact_bps_detailed(impact_bps, zero_for_one)
295            .map_err(to_pyvalue_err)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use std::{str::FromStr, sync::Arc};
302
303    use alloy_primitives::{U160, address};
304    use nautilus_core::UnixNanos;
305    use pyo3::{
306        Python,
307        types::{PyAnyMethods, PyInt},
308    };
309    use rstest::rstest;
310
311    use crate::defi::{
312        AmmType, Blockchain, Chain, Dex, DexType, Pool, PoolIdentifier, Token,
313        pool_analysis::PoolProfiler,
314    };
315
316    #[rstest]
317    fn price_sqrt_ratio_x96_returns_python_int() {
318        let sqrt_price_x96 = U160::from_str("79228162514264337593543950336").unwrap();
319        let mut profiler = PoolProfiler::new(pool());
320        profiler.initialize(sqrt_price_x96).unwrap();
321        Python::initialize();
322
323        Python::attach(|py| {
324            let value = profiler.py_price_sqrt_ratio_x96(py).unwrap();
325            let value = value.bind(py);
326
327            assert!(value.is_instance_of::<PyInt>());
328            assert_eq!(value.str().unwrap().to_string(), sqrt_price_x96.to_string());
329        });
330    }
331
332    fn pool() -> Arc<Pool> {
333        let chain = Arc::new(Chain::new(Blockchain::Ethereum, 1));
334        let dex = Arc::new(Dex::new(
335            (*chain).clone(),
336            DexType::UniswapV3,
337            "0x0000000000000000000000000000000000000fac",
338            1,
339            AmmType::CLAMM,
340            "PoolCreated",
341            "Swap",
342            "Mint",
343            "Burn",
344            "Collect",
345        ));
346        let token0 = Token::new(
347            Arc::clone(&chain),
348            address!("0000000000000000000000000000000000000001"),
349            "USD Coin".to_string(),
350            "USDC".to_string(),
351            6,
352        );
353        let token1 = Token::new(
354            Arc::clone(&chain),
355            address!("0000000000000000000000000000000000000002"),
356            "Wrapped Ether".to_string(),
357            "WETH".to_string(),
358            18,
359        );
360        let pool_address = address!("0000000000000000000000000000000000000003");
361
362        Arc::new(Pool::new(
363            chain,
364            dex,
365            pool_address,
366            PoolIdentifier::from_address(pool_address),
367            1,
368            token0,
369            token1,
370            Some(500),
371            Some(10),
372            UnixNanos::default(),
373        ))
374    }
375}