Skip to main content

nautilus_model/defi/pool_analysis/
size_estimator.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//! Size estimation utilities for DeFi pool profiler.
17//!
18//! This module provides functions for estimating optimal trade sizes based on
19//! target price impact/slippage levels using binary search and liquidity analysis.
20
21use alloy_primitives::U256;
22
23use super::{PoolProfiler, error::PoolEventKind};
24use crate::defi::tick_map::full_math::FullMath;
25
26/// Configuration for size estimation algorithms.
27///
28/// Controls the behavior of the binary search algorithm including convergence
29/// criteria and adaptive bound expansion.
30#[derive(Debug, Clone)]
31pub struct EstimationConfig {
32    /// Enable adaptive upper bound expansion during binary search (default: true).
33    pub enable_adaptive_bounds: bool,
34    /// Maximum number of times to expand upper bound (default: 10).
35    pub max_bound_expansions: u32,
36    /// Binary search tolerance in basis points (default: 1).
37    pub tolerance_bps: u32,
38    /// Maximum iterations for binary search (default: 50).
39    pub max_iterations: u32,
40}
41
42impl Default for EstimationConfig {
43    fn default() -> Self {
44        Self {
45            enable_adaptive_bounds: true,
46            max_bound_expansions: 10,
47            tolerance_bps: 1,
48            max_iterations: 50,
49        }
50    }
51}
52
53/// Detailed result of a size-for-impact search.
54///
55/// Contains diagnostics about the binary search process including
56/// convergence information, iterations taken, bounds used, and final accuracy.
57#[derive(Debug, Clone)]
58#[cfg_attr(
59    feature = "python",
60    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
61)]
62#[cfg_attr(
63    feature = "python",
64    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
65)]
66pub struct SizeForImpactResult {
67    /// Target slippage requested in basis points.
68    pub target_impact_bps: u32,
69    /// Optimal trade size found.
70    pub size: U256,
71    /// Actual slippage at the found size in basis points.
72    pub actual_impact_bps: u32,
73    /// Swap direction (true = token0 for token1).
74    pub zero_for_one: bool,
75    /// Number of binary search iterations performed.
76    pub iterations: u32,
77    /// Whether the search converged successfully.
78    pub converged: bool,
79    /// Number of times the upper bound was expanded.
80    pub expansion_count: u32,
81    /// Initial upper bound estimate used.
82    pub initial_high: U256,
83    /// Final lower bound when search terminated.
84    pub final_low: U256,
85    /// Final upper bound when search terminated.
86    pub final_high: U256,
87}
88
89impl SizeForImpactResult {
90    /// Check if the result is within the specified tolerance.
91    #[must_use]
92    pub fn within_tolerance(&self, tolerance_bps: u32) -> bool {
93        let diff = self.actual_impact_bps.abs_diff(self.target_impact_bps);
94        diff <= tolerance_bps
95    }
96
97    /// Returns the convergence quality as a percentage, where 100.0 is a perfect match.
98    #[must_use]
99    pub fn accuracy_percent(&self) -> f64 {
100        if self.target_impact_bps == 0 {
101            return 100.0;
102        }
103        let diff = f64::from(self.actual_impact_bps.abs_diff(self.target_impact_bps));
104        let target = f64::from(self.target_impact_bps);
105        100.0 - (diff / target * 100.0).min(100.0)
106    }
107}
108
109/// Internal state from binary search algorithm.
110///
111/// Captures all binary search tracking information without timing overhead.
112#[derive(Debug, Clone)]
113struct BinarySearchState {
114    /// Final lower bound when search terminated.
115    low: U256,
116    /// Final upper bound when search terminated.
117    high: U256,
118    /// Initial upper bound estimate used.
119    initial_high: U256,
120    /// Number of binary search iterations performed.
121    iterations: u32,
122    /// Number of times the upper bound was expanded.
123    expansions: u32,
124    /// Whether the search converged successfully.
125    converged: bool,
126    /// Final slippage in bps (if calculated during search).
127    final_slippage_bps: Option<u32>,
128}
129
130/// Estimates the initial trade size bound for a target price impact.
131///
132/// Uses active liquidity, the current square root price, swap direction, and target impact in basis
133/// points, then applies a fixed 2x safety factor. Set `zero_for_one` to `true` for token0-to-token1
134/// swaps. The binary search refines the estimate.
135/// The estimate is clamped to `[10^6, 10^30]` raw token units.
136#[must_use]
137#[allow(
138    clippy::missing_panics_doc,
139    reason = "u128 liquidity times U160 price times u32 impact divided by 2^96 * 10000 fits U256"
140)]
141pub fn estimate_max_size_for_impact(
142    profiler: &PoolProfiler,
143    impact_bps: u32,
144    zero_for_one: bool,
145) -> U256 {
146    let liquidity = profiler.get_active_liquidity();
147    if liquidity == 0 {
148        return U256::from(1_000_000);
149    }
150
151    let sqrt_price = U256::from(profiler.state.price_sqrt_ratio_x96);
152    let q96 = U256::from(1u128) << 96;
153    let liquidity_u256 = U256::from(liquidity);
154    let impact_ratio = U256::from(impact_bps);
155
156    let base = if zero_for_one {
157        (liquidity_u256 * q96 * impact_ratio) / (sqrt_price * U256::from(10000))
158    } else {
159        FullMath::mul_div(
160            liquidity_u256,
161            sqrt_price * impact_ratio,
162            q96 * U256::from(10000),
163        )
164        .expect("Size estimate from u128 liquidity, U160 price, and u32 impact fits U256")
165    };
166
167    // 2x safety factor, clamp to reasonable range
168    let doubled = base * U256::from(2);
169    let min_val = U256::from(1_000_000);
170    let max_val = U256::from(1_000_000_000_000_000_000_000_000_000_000u128);
171
172    if doubled < min_val {
173        min_val
174    } else if doubled > max_val {
175        max_val
176    } else {
177        doubled
178    }
179}
180
181/// Calculates the slippage for a given trade size, where 10,000 basis points is 100%.
182///
183/// Simulates a swap and calculates its total execution cost, including fees, as the difference
184/// between the execution price and the spot price before the swap.
185///
186/// # Errors
187///
188/// Returns an error if:
189/// - The pool is not initialized.
190/// - The swap simulation fails.
191/// - The trade info or slippage calculation fails.
192pub fn slippage_for_size_bps(
193    profiler: &PoolProfiler,
194    size: U256,
195    zero_for_one: bool,
196) -> anyhow::Result<u32> {
197    profiler.check_if_initialized(PoolEventKind::Swap)?;
198
199    if size.is_zero() {
200        return Ok(0);
201    }
202
203    let mut quote = profiler.swap_exact_in(size, zero_for_one, None)?;
204    quote.calculate_trade_info(&profiler.pool.token0, &profiler.pool.token1)?;
205    let trade_info = quote
206        .trade_info
207        .as_ref()
208        .ok_or_else(|| anyhow::anyhow!("Trade info not initialized"))?;
209
210    trade_info.get_slippage_bps()
211}
212
213fn binary_search_for_size(
214    profiler: &PoolProfiler,
215    impact_bps: u32,
216    zero_for_one: bool,
217    config: &EstimationConfig,
218) -> anyhow::Result<BinarySearchState> {
219    // Validate inputs
220    if impact_bps == 0 {
221        anyhow::bail!("Impact must be greater than zero");
222    }
223
224    if impact_bps > 10000 {
225        anyhow::bail!("Impact cannot exceed 100% (10000 bps)");
226    }
227    profiler.check_if_initialized(PoolEventKind::Swap)?;
228
229    // Estimate initial bounds
230    let mut low = U256::ZERO;
231    let mut high = estimate_max_size_for_impact(profiler, impact_bps, zero_for_one);
232    let initial_high = high;
233
234    let mut iterations = 0;
235    let mut expansions = 0;
236    let mut converged = false;
237    let mut final_slippage_bps = None;
238
239    // Binary search with optional adaptive expansion
240    while iterations < config.max_iterations {
241        iterations += 1;
242
243        // Calculate midpoint
244        let mid = (low + high) / U256::from(2);
245
246        if mid.is_zero() {
247            break;
248        }
249
250        // Calculate slippage at midpoint
251        let slippage_mid = if let Ok(s) = slippage_for_size_bps(profiler, mid, zero_for_one) {
252            s
253        } else {
254            // Swap failed, mid too large
255            high = mid;
256            continue;
257        };
258
259        // Check convergence by slippage
260        let diff_bps = slippage_mid.abs_diff(impact_bps);
261        if diff_bps <= config.tolerance_bps {
262            low = mid;
263            final_slippage_bps = Some(slippage_mid);
264            converged = true;
265            break;
266        }
267
268        // Adjust bounds
269        if slippage_mid < impact_bps {
270            low = mid;
271
272            // Adaptive expansion: only expand when the midpoint is within the top 20% of the
273            // upper bound, which indicates the target lies at or beyond it.
274            if config.enable_adaptive_bounds
275                && high - mid <= high / U256::from(5)
276                && expansions < config.max_bound_expansions
277            {
278                high *= U256::from(2);
279                expansions += 1;
280                log::debug!(
281                    "Expanding upper bound (expansion {}/{}): new high={}",
282                    expansions,
283                    config.max_bound_expansions,
284                    high
285                );
286            }
287        } else {
288            high = mid;
289        }
290    }
291
292    if iterations >= config.max_iterations {
293        log::warn!(
294            "Binary search did not converge after {iterations} iterations, returning conservative estimate"
295        );
296    }
297
298    Ok(BinarySearchState {
299        low,
300        high,
301        initial_high,
302        iterations,
303        expansions,
304        converged,
305        final_slippage_bps,
306    })
307}
308
309/// Finds a trade size for a target slippage, including fees.
310///
311/// Uses binary search with optional adaptive upper bound expansion. If the search does not converge
312/// within the configured tolerance, returns its last lower bound.
313///
314/// # Algorithm
315///
316/// 1. Estimate the initial upper bound from active liquidity and price.
317/// 1. Binary search between zero and the upper bound.
318/// 1. Calculate the slippage at each midpoint through simulation.
319/// 1. Adjust the bounds based on whether slippage is above or below the target.
320/// 1. Expand the upper bound when enabled and the midpoint approaches it.
321/// 1. Stop when slippage is within tolerance, the midpoint is zero, or the iteration limit is
322///    reached.
323///
324/// # Errors
325///
326/// Returns an error if:
327/// - The target impact is zero or exceeds 10,000 basis points.
328/// - The pool is not initialized.
329pub fn size_for_impact_bps(
330    profiler: &PoolProfiler,
331    impact_bps: u32,
332    zero_for_one: bool,
333    config: &EstimationConfig,
334) -> anyhow::Result<U256> {
335    let state = binary_search_for_size(profiler, impact_bps, zero_for_one, config)?;
336    Ok(state.low)
337}
338
339/// Finds a trade size with detailed search diagnostics.
340///
341/// This is the detailed version of [`size_for_impact_bps`]. It returns the convergence status,
342/// iteration and expansion counts, final slippage, and search bounds. The target impact includes
343/// fees and uses basis points. Set `zero_for_one` to `true` for token0-to-token1 swaps.
344///
345/// # Errors
346///
347/// Returns an error if:
348/// - The target impact is zero or exceeds 10,000 basis points.
349/// - The pool is not initialized.
350/// - The final slippage calculation fails.
351pub fn size_for_impact_bps_detailed(
352    profiler: &PoolProfiler,
353    impact_bps: u32,
354    zero_for_one: bool,
355    config: &EstimationConfig,
356) -> anyhow::Result<SizeForImpactResult> {
357    let state = binary_search_for_size(profiler, impact_bps, zero_for_one, config)?;
358
359    // Get actual slippage - reuse from state if available to avoid redundant calculation
360    let actual_impact = if let Some(slippage) = state.final_slippage_bps {
361        slippage
362    } else if state.low.is_zero() {
363        0
364    } else {
365        slippage_for_size_bps(profiler, state.low, zero_for_one)?
366    };
367
368    Ok(SizeForImpactResult {
369        target_impact_bps: impact_bps,
370        size: state.low,
371        actual_impact_bps: actual_impact,
372        zero_for_one,
373        iterations: state.iterations,
374        converged: state.converged,
375        expansion_count: state.expansions,
376        initial_high: state.initial_high,
377        final_low: state.low,
378        final_high: state.high,
379    })
380}