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;
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.core.nautilus_pyo3.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    /// Get the convergence quality as a percentage.
97    ///
98    /// # Returns
99    /// Accuracy percentage (100.0 = perfect match, lower = less accurate)
100    #[must_use]
101    pub fn accuracy_percent(&self) -> f64 {
102        if self.target_impact_bps == 0 {
103            return 100.0;
104        }
105        let diff = f64::from(self.actual_impact_bps.abs_diff(self.target_impact_bps));
106        let target = f64::from(self.target_impact_bps);
107        100.0 - (diff / target * 100.0).min(100.0)
108    }
109}
110
111/// Internal state from binary search algorithm.
112///
113/// Used by the private helper function to return all tracking information
114/// without timing overhead.
115#[derive(Debug, Clone)]
116struct BinarySearchState {
117    /// Final lower bound when search terminated.
118    low: U256,
119    /// Final upper bound when search terminated.
120    high: U256,
121    /// Initial upper bound estimate used.
122    initial_high: U256,
123    /// Number of binary search iterations performed.
124    iterations: u32,
125    /// Number of times the upper bound was expanded.
126    expansions: u32,
127    /// Whether the search converged successfully.
128    converged: bool,
129    /// Final slippage in bps (if calculated during search).
130    final_slippage_bps: Option<u32>,
131}
132
133/// Estimates the maximum trade size for a given impact target.
134///
135/// Uses a simple heuristic: size ≈ liquidity × `price_factor` × `impact_ratio` × `safety_multiplier`
136/// The binary search will refine this estimate, so perfect accuracy isn't needed.
137///
138/// # Arguments
139/// * `profiler` - Reference to the pool profiler
140/// * `impact_bps` - Target impact in basis points
141/// * `zero_for_one` - Swap direction
142/// * `config` - Estimation configuration (only uses `safety_multiplier`)
143///
144/// # Returns
145/// Estimated maximum size as U256
146#[must_use]
147pub fn estimate_max_size_for_impact(
148    profiler: &PoolProfiler,
149    impact_bps: u32,
150    zero_for_one: bool,
151) -> U256 {
152    let liquidity = profiler.get_active_liquidity();
153    if liquidity == 0 {
154        return U256::from(1_000_000);
155    }
156
157    let sqrt_price = U256::from(profiler.state.price_sqrt_ratio_x96);
158    let q96 = U256::from(1u128) << 96;
159    let liquidity_u256 = U256::from(liquidity);
160    let impact_ratio = U256::from(impact_bps);
161
162    let base = if zero_for_one {
163        (liquidity_u256 * q96 * impact_ratio) / (sqrt_price * U256::from(10000))
164    } else {
165        (liquidity_u256 * sqrt_price * impact_ratio) / (q96 * U256::from(10000))
166    };
167
168    // 2x safety factor, clamp to reasonable range
169    let doubled = base * U256::from(2);
170    let min_val = U256::from(1_000_000);
171    let max_val = U256::from(1_000_000_000_000_000_000_000_000_000_000u128);
172
173    if doubled < min_val {
174        min_val
175    } else if doubled > max_val {
176        max_val
177    } else {
178        doubled
179    }
180}
181
182/// Calculates the slippage in basis points for a given trade size.
183///
184/// This function simulates a swap with the specified size and returns the slippage
185/// (total execution cost including fees) in basis points. Slippage is calculated
186/// as the difference between the execution price and the spot price before the swap.
187///
188/// # Returns
189/// Slippage in basis points (10000 = 100%)
190///
191/// # Errors
192/// Returns error if:
193/// - Pool is not initialized
194/// - Swap simulation fails
195/// - Trade info calculation fails
196pub fn slippage_for_size_bps(
197    profiler: &PoolProfiler,
198    size: U256,
199    zero_for_one: bool,
200) -> anyhow::Result<u32> {
201    profiler.check_if_initialized();
202
203    if size.is_zero() {
204        return Ok(0);
205    }
206
207    let mut quote = profiler.swap_exact_in(size, zero_for_one, None)?;
208    quote.calculate_trade_info(&profiler.pool.token0, &profiler.pool.token1)?;
209    let trade_info = quote
210        .trade_info
211        .as_ref()
212        .ok_or_else(|| anyhow::anyhow!("Trade info not initialized"))?;
213
214    trade_info.get_slippage_bps()
215}
216
217/// Core binary search algorithm for finding optimal trade size.
218///
219/// # Returns
220/// State containing final bounds, iterations, convergence status, and optionally
221/// the final slippage if it was calculated during convergence.
222fn binary_search_for_size(
223    profiler: &PoolProfiler,
224    impact_bps: u32,
225    zero_for_one: bool,
226    config: &EstimationConfig,
227) -> anyhow::Result<BinarySearchState> {
228    // Validate inputs
229    if impact_bps == 0 {
230        anyhow::bail!("Impact must be greater than zero");
231    }
232
233    if impact_bps > 10000 {
234        anyhow::bail!("Impact cannot exceed 100% (10000 bps)");
235    }
236    profiler.check_if_initialized();
237
238    // Estimate initial bounds
239    let mut low = U256::ZERO;
240    let mut high = estimate_max_size_for_impact(profiler, impact_bps, zero_for_one);
241    let initial_high = high;
242
243    let mut iterations = 0;
244    let mut expansions = 0;
245    let mut converged = false;
246    let mut final_slippage_bps = None;
247
248    // Binary search with optional adaptive expansion
249    while iterations < config.max_iterations {
250        iterations += 1;
251
252        // Calculate midpoint
253        let mid = (low + high) / U256::from(2);
254
255        if mid.is_zero() {
256            break;
257        }
258
259        // Calculate slippage at midpoint
260        let slippage_mid = if let Ok(s) = slippage_for_size_bps(profiler, mid, zero_for_one) {
261            s
262        } else {
263            // Swap failed, mid too large
264            high = mid;
265            continue;
266        };
267
268        // Check convergence by slippage
269        let diff_bps = slippage_mid.abs_diff(impact_bps);
270        if diff_bps <= config.tolerance_bps {
271            low = mid;
272            final_slippage_bps = Some(slippage_mid);
273            converged = true;
274            break;
275        }
276
277        // Adjust bounds
278        if slippage_mid < impact_bps {
279            low = mid;
280
281            // Adaptive expansion: only expand when midpoint is in the top 20% of the range
282            // This indicates we're approaching the upper bound
283            let range = high - low;
284            let threshold = range / U256::from(5); // 20% of range
285
286            if config.enable_adaptive_bounds
287                && high - mid <= threshold
288                && expansions < config.max_bound_expansions
289            {
290                high *= U256::from(2);
291                expansions += 1;
292                log::debug!(
293                    "Expanding upper bound (expansion {}/{}): new high={}",
294                    expansions,
295                    config.max_bound_expansions,
296                    high
297                );
298            }
299        } else {
300            high = mid;
301        }
302    }
303
304    if iterations >= config.max_iterations {
305        log::warn!(
306            "Binary search did not converge after {iterations} iterations, returning conservative estimate"
307        );
308    }
309
310    Ok(BinarySearchState {
311        low,
312        high,
313        initial_high,
314        iterations,
315        expansions,
316        converged,
317        final_slippage_bps,
318    })
319}
320
321/// Finds the maximum trade size that produces a target slippage (including fees).
322///
323/// Uses binary search with optional adaptive upper bound expansion to find the
324/// largest trade size that results in slippage at or below the target. The method
325/// iteratively simulates swaps at different sizes until it converges to the optimal
326/// size within the specified tolerance.
327///
328/// # Algorithm
329/// 1. Estimate initial upper bound using hybrid strategy (heuristic + liquidity scan)
330/// 2. Binary search between 0 and upper bound
331/// 3. For each midpoint, calculate actual slippage via simulation
332/// 4. Adjust bounds based on whether slippage is above or below target
333/// 5. If adaptive bounds enabled and upper bound reached, expand and continue
334/// 6. Converge when slippage is within tolerance or size delta is minimal
335///
336/// # Returns
337/// The maximum trade size (U256) that produces the target slippage
338///
339/// # Errors
340/// Returns error if:
341/// - Impact is zero or exceeds 100% (10000 bps)
342/// - Pool is not initialized
343/// - Swap simulations fail
344pub fn size_for_impact_bps(
345    profiler: &PoolProfiler,
346    impact_bps: u32,
347    zero_for_one: bool,
348    config: &EstimationConfig,
349) -> anyhow::Result<U256> {
350    let state = binary_search_for_size(profiler, impact_bps, zero_for_one, config)?;
351    Ok(state.low)
352}
353
354/// Finds the maximum trade size with detailed search diagnostics.
355///
356/// This is the detailed version of [`size_for_impact_bps`] that returns detailed
357/// information about the search process including convergence metrics, iterations,
358/// bounds used, and timing information.
359///
360/// # Arguments
361/// * `profiler` - Reference to the pool profiler
362/// * `impact_bps` - Target slippage in basis points (including fees)
363/// * `zero_for_one` - Swap direction (true = token0 for token1)
364/// * `config` - Estimation configuration
365///
366/// # Returns
367/// Detailed result containing size, search metrics, and convergence information
368///
369/// # Errors
370/// Returns error if:
371/// - Impact is zero or exceeds 100% (10000 bps)
372/// - Pool is not initialized
373/// - Swap simulations fail
374pub fn size_for_impact_bps_detailed(
375    profiler: &PoolProfiler,
376    impact_bps: u32,
377    zero_for_one: bool,
378    config: &EstimationConfig,
379) -> anyhow::Result<SizeForImpactResult> {
380    let state = binary_search_for_size(profiler, impact_bps, zero_for_one, config)?;
381
382    // Get actual slippage - reuse from state if available to avoid redundant calculation
383    let actual_impact = if let Some(slippage) = state.final_slippage_bps {
384        slippage
385    } else if state.low.is_zero() {
386        0
387    } else {
388        slippage_for_size_bps(profiler, state.low, zero_for_one)?
389    };
390
391    Ok(SizeForImpactResult {
392        target_impact_bps: impact_bps,
393        size: state.low,
394        actual_impact_bps: actual_impact,
395        zero_for_one,
396        iterations: state.iterations,
397        converged: state.converged,
398        expansion_count: state.expansions,
399        initial_high: state.initial_high,
400        final_low: state.low,
401        final_high: state.high,
402    })
403}