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}