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}