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}