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