nautilus_model/defi/pool_analysis/profiler.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//! Pool profiling utilities for analyzing DeFi pool event data.
17
18use ahash::AHashMap;
19use alloy_primitives::{Address, I256, U160, U256};
20
21use crate::defi::{
22 PoolLiquidityUpdate, PoolSwap, SharedPool,
23 data::{
24 DexPoolData, PoolFeeCollect, PoolLiquidityUpdateType, block::BlockPosition,
25 flash::PoolFlash,
26 },
27 pool_analysis::{
28 position::PoolPosition,
29 quote::SwapQuote,
30 size_estimator,
31 snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
32 swap_math::compute_swap_step,
33 },
34 reporting::{BlockchainSyncReportItems, BlockchainSyncReporter},
35 tick_map::{
36 TickMap,
37 full_math::{FullMath, Q128},
38 liquidity_math::liquidity_math_add,
39 sqrt_price_math::{get_amount0_delta, get_amount1_delta, get_amounts_for_liquidity},
40 tick::{CrossedTick, PoolTick},
41 tick_math::{
42 MAX_SQRT_RATIO, MIN_SQRT_RATIO, get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio,
43 },
44 },
45};
46
47/// A DeFi pool state tracker and event processor for UniswapV3-style AMM pools.
48///
49/// The `PoolProfiler` provides complete pool state management including:
50/// - Liquidity position tracking and management.
51/// - Tick crossing and price movement simulation.
52/// - Fee accumulation and distribution tracking.
53/// - Protocol fee calculation.
54/// - Pool state validation and maintenance.
55///
56/// This profiler can both process historical events and execute new operations,
57/// making it suitable for both backtesting and simulation scenarios.
58///
59/// # Usage
60///
61/// Create a new profiler with a pool definition, initialize it with a starting price,
62/// then either process historical events or execute new pool operations to simulate
63/// trading activity and analyze pool behavior.
64#[derive(Debug, Clone)]
65#[cfg_attr(
66 feature = "python",
67 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
68)]
69#[cfg_attr(
70 feature = "python",
71 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
72)]
73pub struct PoolProfiler {
74 /// Pool definition.
75 pub pool: SharedPool,
76 /// Position tracking by position key (`owner:tick_lower:tick_upper`).
77 positions: AHashMap<String, PoolPosition>,
78 /// Tick map managing liquidity distribution across price ranges.
79 pub tick_map: TickMap,
80 /// Global pool state including current price, tick, and cumulative flows with fees.
81 pub state: PoolState,
82 /// Analytics counters tracking pool operations and performance metrics.
83 pub analytics: PoolAnalytics,
84 /// The block position of the last processed event.
85 pub last_processed_event: Option<BlockPosition>,
86 /// Flag indicating whether the pool has been initialized with a starting price.
87 pub is_initialized: bool,
88 /// Optional progress reporter for tracking event processing.
89 reporter: Option<BlockchainSyncReporter>,
90 /// The last block number that was reported (used for progress tracking).
91 last_reported_block: u64,
92}
93
94impl PoolProfiler {
95 /// Creates a new [`PoolProfiler`] instance for tracking pool state and events.
96 ///
97 /// # Panics
98 ///
99 /// Panics if the pool's tick spacing is not set.
100 #[must_use]
101 pub fn new(pool: SharedPool) -> Self {
102 let tick_spacing = pool.tick_spacing.expect("Pool tick spacing must be set");
103 Self {
104 pool,
105 positions: AHashMap::new(),
106 tick_map: TickMap::new(tick_spacing),
107 state: PoolState::default(),
108 analytics: PoolAnalytics::default(),
109 last_processed_event: None,
110 is_initialized: false,
111 reporter: None,
112 last_reported_block: 0,
113 }
114 }
115
116 /// Initializes the pool with a starting price and activates the profiler.
117 ///
118 /// # Panics
119 ///
120 /// This function panics if:
121 /// - Pool is already initialized (checked via `is_initialized` flag)
122 /// - Calculated tick from price doesn't match pool's `initial_tick` (if set)
123 pub fn initialize(&mut self, price_sqrt_ratio_x96: U160) {
124 assert!(!self.is_initialized, "Pool already initialized");
125
126 let calculated_tick = get_tick_at_sqrt_ratio(price_sqrt_ratio_x96);
127
128 if let Some(initial_tick) = self.pool.initial_tick {
129 assert_eq!(
130 initial_tick, calculated_tick,
131 "Calculated tick does not match pool initial tick"
132 );
133 }
134
135 log::info!(
136 "Initializing pool profiler with tick {calculated_tick} and price sqrt ratio {price_sqrt_ratio_x96}"
137 );
138
139 self.state.current_tick = calculated_tick;
140 self.state.price_sqrt_ratio_x96 = price_sqrt_ratio_x96;
141 self.is_initialized = true;
142 }
143
144 /// Verifies that the pool has been initialized.
145 ///
146 /// # Panics
147 ///
148 /// Panics if the pool hasn't been initialized with a starting price via [`initialize()`](Self::initialize).
149 pub fn check_if_initialized(&self) {
150 assert!(self.is_initialized, "Pool is not initialized");
151 }
152
153 /// Processes a historical pool event and updates internal state.
154 ///
155 /// Handles all types of pool events (swaps, mints, burns, fee collections),
156 /// and updates the profiler's internal state accordingly. This is the main
157 /// entry point for processing historical blockchain events.
158 ///
159 /// # Errors
160 ///
161 /// This function returns an error if:
162 /// - Pool is not initialized.
163 /// - Event contains invalid data (tick ranges, amounts).
164 /// - Mathematical operations overflow.
165 pub fn process(&mut self, event: &DexPoolData) -> anyhow::Result<()> {
166 if self.check_if_already_processed(
167 event.block_number(),
168 event.transaction_index(),
169 event.log_index(),
170 ) {
171 return Ok(());
172 }
173
174 match event {
175 DexPoolData::Swap(swap) => self.process_swap(swap)?,
176 DexPoolData::LiquidityUpdate(update) => match update.kind {
177 PoolLiquidityUpdateType::Mint => self.process_mint(update)?,
178 PoolLiquidityUpdateType::Burn => self.process_burn(update)?,
179 },
180 DexPoolData::FeeCollect(collect) => self.process_collect(collect)?,
181 DexPoolData::Flash(flash) => self.process_flash(flash)?,
182 }
183 self.update_reporter_if_enabled(event.block_number());
184
185 Ok(())
186 }
187
188 // Checks if we need to skip events at or before the last processed event to prevent double-processing.
189 fn check_if_already_processed(&self, block: u64, tx_idx: u32, log_idx: u32) -> bool {
190 if let Some(last_event) = &self.last_processed_event {
191 let should_skip = block < last_event.number
192 || (block == last_event.number && tx_idx < last_event.transaction_index)
193 || (block == last_event.number
194 && tx_idx == last_event.transaction_index
195 && log_idx <= last_event.log_index);
196
197 if should_skip {
198 log::debug!(
199 "Skipping already processed event at block {block} tx {tx_idx} log {log_idx}"
200 );
201 }
202 return should_skip;
203 }
204
205 false
206 }
207
208 /// Auto-updates reporter if it's enabled.
209 fn update_reporter_if_enabled(&mut self, current_block: u64) {
210 // Auto-update reporter if enabled
211 if let Some(reporter) = &mut self.reporter {
212 let blocks_processed = current_block.saturating_sub(self.last_reported_block);
213
214 if blocks_processed > 0 {
215 reporter.update(blocks_processed as usize);
216 self.last_reported_block = current_block;
217
218 if reporter.should_log_progress(current_block, current_block) {
219 reporter.log_progress(current_block);
220 }
221 }
222 }
223 }
224
225 // panics-doc-ok (transitive via check_if_initialized)
226 /// Processes a historical swap event from blockchain data.
227 ///
228 /// Replays the swap by simulating it through [`Self::simulate_swap_through_ticks`],
229 /// then verifies the simulation results against the actual event data. If mismatches
230 /// are detected (tick or liquidity), the pool state is corrected to match the event
231 /// values and warnings are logged.
232 ///
233 /// This self-healing approach ensures pool state stays synchronized with on-chain
234 /// reality even if simulation logic differs slightly from actual contract behavior.
235 ///
236 /// # Use Case
237 ///
238 /// Historical event processing when rebuilding pool state from blockchain events.
239 ///
240 /// # Errors
241 ///
242 /// This function returns an error if:
243 /// - Pool initialization checks fail.
244 /// - Swap simulation fails (see [`Self::simulate_swap_through_ticks`] errors).
245 ///
246 /// # Panics
247 ///
248 /// Panics if the pool has not been initialized.
249 pub fn process_swap(&mut self, swap: &PoolSwap) -> anyhow::Result<()> {
250 self.check_if_initialized();
251
252 if self.check_if_already_processed(swap.block, swap.transaction_index, swap.log_index) {
253 return Ok(());
254 }
255
256 let zero_for_one = swap.amount0.is_positive();
257 let amount_specified = if zero_for_one {
258 swap.amount0
259 } else {
260 swap.amount1
261 };
262 // For price limit use the final sqrt price from swap, which is a
263 // good proxy to price limit
264 let sqrt_price_limit_x96 = swap.sqrt_price_x96;
265 let swap_quote =
266 self.simulate_swap_through_ticks(amount_specified, zero_for_one, sqrt_price_limit_x96)?;
267 self.apply_swap_quote(&swap_quote);
268
269 // Verify simulation against event data - correct with event values if mismatch detected
270 if swap.tick != self.state.current_tick {
271 log::error!(
272 "Inconsistency in swap processing: Current tick mismatch: simulated {}, event {} on block {}",
273 self.state.current_tick,
274 swap.tick,
275 swap.block
276 );
277 self.state.current_tick = swap.tick;
278 }
279
280 if swap.liquidity != self.tick_map.liquidity {
281 log::error!(
282 "Inconsistency in swap processing: Active liquidity mismatch: simulated {}, event {} on block {}",
283 self.tick_map.liquidity,
284 swap.liquidity,
285 swap.block
286 );
287 self.tick_map.liquidity = swap.liquidity;
288 }
289
290 self.last_processed_event = Some(BlockPosition::new(
291 swap.block,
292 swap.transaction_hash.clone(),
293 swap.transaction_index,
294 swap.log_index,
295 ));
296 self.update_reporter_if_enabled(swap.block);
297 self.update_liquidity_analytics();
298
299 Ok(())
300 }
301
302 // panics-doc-ok (transitive via check_if_initialized)
303 /// Executes a new simulated swap and returns the resulting event.
304 ///
305 /// This is the public API for forward simulation of swap operations. It delegates
306 /// the core swap mathematics to [`Self::simulate_swap_through_ticks`], then wraps
307 /// the results in a [`PoolSwap`] event structure with full metadata.
308 ///
309 /// # Errors
310 ///
311 /// Returns errors from [`Self::simulate_swap_through_ticks`]:
312 /// - Pool metadata missing or invalid
313 /// - Price limit violations
314 /// - Arithmetic overflow in fee or liquidity calculations
315 ///
316 /// # Panics
317 ///
318 /// This function panics if:
319 /// - Pool fee is not initialized
320 /// - Pool is not initialized
321 pub fn execute_swap(
322 &mut self,
323 sender: Address,
324 recipient: Address,
325 block: BlockPosition,
326 zero_for_one: bool,
327 amount_specified: I256,
328 sqrt_price_limit_x96: U160,
329 ) -> anyhow::Result<PoolSwap> {
330 self.check_if_initialized();
331 let swap_quote =
332 self.simulate_swap_through_ticks(amount_specified, zero_for_one, sqrt_price_limit_x96)?;
333 self.apply_swap_quote(&swap_quote);
334
335 let swap_event = PoolSwap::new(
336 self.pool.chain.clone(),
337 self.pool.dex.clone(),
338 self.pool.instrument_id,
339 self.pool.pool_identifier,
340 block.number,
341 block.transaction_hash,
342 block.transaction_index,
343 block.log_index,
344 None,
345 sender,
346 recipient,
347 swap_quote.amount0,
348 swap_quote.amount1,
349 self.state.price_sqrt_ratio_x96,
350 self.tick_map.liquidity,
351 self.state.current_tick,
352 );
353 Ok(swap_event)
354 }
355
356 /// Core **read-only** swap simulation engine implementing `UniswapV3` mathematics.
357 ///
358 /// This method performs a complete swap simulation without modifying pool state,
359 /// working entirely on stack-allocated local copies of state variables. It returns
360 /// a [`SwapQuote`] containing all swap results and profiling data,
361 /// including a complete audit trail of crossed ticks.
362 ///
363 ///
364 /// # Algorithm Overview
365 ///
366 /// 1. **Iterative price curve traversal**: Walks through liquidity ranges until
367 /// the input/output amount is exhausted or the price limit is reached
368 /// 2. **Tick crossing tracking**: When crossing initialized tick boundaries, records
369 /// the crossing in `crossed_ticks` vector with complete state snapshot (tick, direction, fee growth)
370 /// 3. **Local liquidity updates**: Tracks liquidity changes in local variables by reading
371 /// `liquidity_net` from tick map (read-only, no mutations)
372 /// 4. **Fee calculation**: Splits fees between LPs and protocol, accumulates in local variables
373 /// 5. **Quote assembly**: Returns [`SwapQuote`] with amounts, prices, fees, and crossed tick data
374 ///
375 /// # Errors
376 ///
377 /// Returns error if:
378 /// - Pool fee is not configured
379 /// - Fee growth arithmetic overflows when scaling by liquidity
380 /// - Swap step calculations fail
381 ///
382 /// # Panics
383 ///
384 /// Panics if pool is not initialized
385 pub fn simulate_swap_through_ticks(
386 &self,
387 amount_specified: I256,
388 zero_for_one: bool,
389 sqrt_price_limit_x96: U160,
390 ) -> anyhow::Result<SwapQuote> {
391 let mut current_sqrt_price = self.state.price_sqrt_ratio_x96;
392 let mut current_tick = self.state.current_tick;
393 let mut current_active_liquidity = self.tick_map.liquidity;
394 let exact_input = amount_specified.is_positive();
395 let mut amount_specified_remaining = amount_specified;
396 let mut amount_calculated = I256::ZERO;
397 let mut protocol_fee = U256::ZERO;
398 let mut lp_fee = U256::ZERO;
399 let mut crossed_ticks = Vec::new();
400 let fee_tier = self.pool.fee.expect("Pool fee should be initialized");
401 // Swapping cache variables
402 let fee_protocol = if zero_for_one {
403 // Extract lower 4 bits for token0 protocol fee
404 self.state.fee_protocol % 16
405 } else {
406 // Extract upper 4 bits for token1 protocol fee
407 self.state.fee_protocol >> 4
408 };
409
410 // Track current fee growth during swap
411 let mut current_fee_growth_global = if zero_for_one {
412 self.state.fee_growth_global_0
413 } else {
414 self.state.fee_growth_global_1
415 };
416
417 // Continue swapping as long as we haven't used the entire input/output or haven't reached the price limit
418 while amount_specified_remaining != I256::ZERO && sqrt_price_limit_x96 != current_sqrt_price
419 {
420 let sqrt_price_start_x96 = current_sqrt_price;
421
422 let (mut tick_next, initialized) = self
423 .tick_map
424 .next_initialized_tick(current_tick, zero_for_one);
425
426 // Make sure we do not overshoot MIN/MAX tick
427 tick_next = tick_next.clamp(PoolTick::MIN_TICK, PoolTick::MAX_TICK);
428
429 // Get the price for the next tick
430 let sqrt_price_next = get_sqrt_ratio_at_tick(tick_next);
431
432 // Compute values to swap to the target tick, price limit, or point where input/output amount is exhausted
433 let sqrt_price_target = if (zero_for_one && sqrt_price_next < sqrt_price_limit_x96)
434 || (!zero_for_one && sqrt_price_next > sqrt_price_limit_x96)
435 {
436 sqrt_price_limit_x96
437 } else {
438 sqrt_price_next
439 };
440 let swap_step_result = compute_swap_step(
441 current_sqrt_price,
442 sqrt_price_target,
443 current_active_liquidity,
444 amount_specified_remaining,
445 fee_tier,
446 )?;
447
448 // Update current price to the new price after this swap step (BEFORE amount updates, matching Solidity)
449 current_sqrt_price = swap_step_result.sqrt_ratio_next_x96;
450
451 // Update amounts based on swap direction and type
452 if exact_input {
453 // For exact input swaps: subtract input amount and fees from remaining, subtract output from calculated
454 amount_specified_remaining -= FullMath::truncate_to_i256(
455 swap_step_result.amount_in + swap_step_result.fee_amount,
456 );
457 amount_calculated -= FullMath::truncate_to_i256(swap_step_result.amount_out);
458 } else {
459 // For exact output swaps: add output to remaining, add input and fees to calculated
460 amount_specified_remaining +=
461 FullMath::truncate_to_i256(swap_step_result.amount_out);
462 amount_calculated += FullMath::truncate_to_i256(
463 swap_step_result.amount_in + swap_step_result.fee_amount,
464 );
465 }
466
467 // Calculate protocol fee if enabled
468 let mut step_fee_amount = swap_step_result.fee_amount;
469
470 if fee_protocol > 0 {
471 let protocol_fee_delta = swap_step_result.fee_amount / U256::from(fee_protocol);
472 step_fee_amount -= protocol_fee_delta;
473 protocol_fee += protocol_fee_delta;
474 }
475
476 // Accumulate LP fee (protocol fee is already deducted if it exists).
477 lp_fee += step_fee_amount;
478
479 // Update global fee tracker
480 if current_active_liquidity > 0 {
481 let fee_growth_delta =
482 FullMath::mul_div(step_fee_amount, Q128, U256::from(current_active_liquidity))?;
483 current_fee_growth_global += fee_growth_delta;
484 }
485
486 // Shift tick if we reached the next price
487 if swap_step_result.sqrt_ratio_next_x96 == sqrt_price_next {
488 // We have swapped all the way to the boundary of the next tick.
489 // Time to handle crossing into the next tick, which may change liquidity.
490 // If the tick is initialized, run the tick transition logic (liquidity changes, fee accumulators, etc.).
491 if initialized {
492 crossed_ticks.push(CrossedTick::new(
493 tick_next,
494 zero_for_one,
495 if zero_for_one {
496 current_fee_growth_global
497 } else {
498 self.state.fee_growth_global_0
499 },
500 if zero_for_one {
501 self.state.fee_growth_global_1
502 } else {
503 current_fee_growth_global
504 },
505 ));
506
507 // Update local liquidity tracking when crossing ticks
508 if let Some(tick_data) = self.tick_map.get_tick(tick_next) {
509 let liquidity_net = tick_data.liquidity_net;
510 current_active_liquidity = if zero_for_one {
511 liquidity_math_add(current_active_liquidity, -liquidity_net)
512 } else {
513 liquidity_math_add(current_active_liquidity, liquidity_net)
514 };
515 }
516 }
517
518 current_tick = if zero_for_one {
519 tick_next - 1
520 } else {
521 tick_next
522 };
523 } else if swap_step_result.sqrt_ratio_next_x96 != sqrt_price_start_x96 {
524 // The price moved during this swap step, but didn't reach a tick boundary.
525 // So, update the tick to match the new price.
526 current_tick = get_tick_at_sqrt_ratio(current_sqrt_price);
527 }
528 }
529
530 // Calculate final amounts
531 let (amount0, amount1) = if zero_for_one == exact_input {
532 (
533 amount_specified - amount_specified_remaining,
534 amount_calculated,
535 )
536 } else {
537 (
538 amount_calculated,
539 amount_specified - amount_specified_remaining,
540 )
541 };
542
543 let quote = SwapQuote::new(
544 self.pool.instrument_id,
545 amount0,
546 amount1,
547 self.state.price_sqrt_ratio_x96,
548 current_sqrt_price,
549 self.state.current_tick,
550 current_tick,
551 current_active_liquidity,
552 current_fee_growth_global,
553 lp_fee,
554 protocol_fee,
555 crossed_ticks,
556 );
557 Ok(quote)
558 }
559
560 /// Applies a swap quote to the pool state (mutations only, no simulation).
561 ///
562 /// This private method takes a [`SwapQuote`] generated by [`Self::simulate_swap_through_ticks`]
563 /// and applies its state changes to the pool, including:
564 /// - Price and tick updates
565 /// - Fee growth and protocol fee accumulation
566 /// - Tick crossing mutations (updating tick fee accumulators and active liquidity)
567 pub fn apply_swap_quote(&mut self, swap_quote: &SwapQuote) {
568 // Update price and tick.
569 self.state.current_tick = swap_quote.tick_after;
570 self.state.price_sqrt_ratio_x96 = swap_quote.sqrt_price_after_x96;
571
572 // Update fee growth and protocol fees based on swap direction.
573 if swap_quote.zero_for_one() {
574 self.state.fee_growth_global_0 = swap_quote.fee_growth_global_after;
575 self.state.protocol_fees_token0 += swap_quote.protocol_fee;
576 } else {
577 self.state.fee_growth_global_1 = swap_quote.fee_growth_global_after;
578 self.state.protocol_fees_token1 += swap_quote.protocol_fee;
579 }
580
581 // Apply tick crossings efficiently - only update crossed ticks
582 for crossed in &swap_quote.crossed_ticks {
583 let liquidity_net =
584 self.tick_map
585 .cross_tick(crossed.tick, crossed.fee_growth_0, crossed.fee_growth_1);
586
587 // Update active liquidity based on crossing direction
588 self.tick_map.liquidity = if crossed.zero_for_one {
589 liquidity_math_add(self.tick_map.liquidity, -liquidity_net)
590 } else {
591 liquidity_math_add(self.tick_map.liquidity, liquidity_net)
592 };
593 }
594 self.analytics.total_swaps += 1;
595
596 debug_assert_eq!(
597 self.tick_map.liquidity, swap_quote.liquidity_after,
598 "Liquidity mismatch in apply_swap_quote: computed={}, quote={}",
599 self.tick_map.liquidity, swap_quote.liquidity_after
600 );
601 }
602
603 // panics-doc-ok (transitive via check_if_initialized)
604 /// Returns a swap quote without modifying pool state.
605 ///
606 /// This method simulates a swap and provides detailed profiling metrics including:
607 /// - Amounts of tokens that would be exchanged
608 /// - Price before and after the swap
609 /// - Fee breakdown (LP fees and protocol fees)
610 /// - List of crossed ticks with state snapshots
611 ///
612 /// # Errors
613 ///
614 /// Returns error if:
615 /// - Pool fee is not configured
616 /// - Fee growth arithmetic overflows when scaling by liquidity
617 /// - Swap step calculations fail
618 ///
619 /// # Panics
620 ///
621 /// Panics if pool is not initialized.
622 pub fn quote_swap(
623 &self,
624 amount_specified: I256,
625 zero_for_one: bool,
626 sqrt_price_limit_x96: Option<U160>,
627 ) -> anyhow::Result<SwapQuote> {
628 self.check_if_initialized();
629
630 if amount_specified.is_zero() {
631 anyhow::bail!("Cannot quote swap with zero amount");
632 }
633
634 if let Some(price_limit) = sqrt_price_limit_x96 {
635 self.validate_price_limit(price_limit, zero_for_one)?;
636 }
637
638 let limit = sqrt_price_limit_x96.unwrap_or_else(|| {
639 if zero_for_one {
640 MIN_SQRT_RATIO + U160::from(1)
641 } else {
642 MAX_SQRT_RATIO - U160::from(1)
643 }
644 });
645
646 self.simulate_swap_through_ticks(amount_specified, zero_for_one, limit)
647 }
648
649 /// Simulates an exact input swap (know input amount, calculate output amount).
650 ///
651 /// # Errors
652 /// Returns error if pool is not initialized, input is zero, or price limit is invalid
653 pub fn swap_exact_in(
654 &self,
655 amount_in: U256,
656 zero_for_one: bool,
657 sqrt_price_limit_x96: Option<U160>,
658 ) -> anyhow::Result<SwapQuote> {
659 // Positive = exact input.
660 let amount_specified = I256::from(amount_in);
661 let quote = self.quote_swap(amount_specified, zero_for_one, sqrt_price_limit_x96)?;
662
663 Ok(quote)
664 }
665
666 /// Simulates an exact output swap (know output amount, calculate required input amount).
667 ///
668 /// # Errors
669 /// Returns error if pool is not initialized, output is zero, price limit is invalid,
670 /// or insufficient liquidity exists to fulfill the exact output amount
671 pub fn swap_exact_out(
672 &self,
673 amount_out: U256,
674 zero_for_one: bool,
675 sqrt_price_limit_x96: Option<U160>,
676 ) -> anyhow::Result<SwapQuote> {
677 // Negative = exact output.
678 let amount_specified = -I256::from(amount_out);
679 let quote = self.quote_swap(amount_specified, zero_for_one, sqrt_price_limit_x96)?;
680 quote.validate_exact_output(amount_out)?;
681
682 Ok(quote)
683 }
684
685 /// Simulates a swap to move the pool price down to a target price.
686 ///
687 /// # Errors
688 /// Returns error if pool is not initialized or price limit is invalid.
689 pub fn swap_to_lower_sqrt_price(
690 &self,
691 sqrt_price_limit_x96: U160,
692 ) -> anyhow::Result<SwapQuote> {
693 self.quote_swap(I256::MAX, true, Some(sqrt_price_limit_x96))
694 }
695
696 /// Simulates a swap to move the pool price up to a target price.
697 ///
698 /// # Errors
699 /// Returns error if pool is not initialized or price limit is invalid.
700 pub fn swap_to_higher_sqrt_price(
701 &self,
702 sqrt_price_limit_x96: U160,
703 ) -> anyhow::Result<SwapQuote> {
704 self.quote_swap(I256::MAX, false, Some(sqrt_price_limit_x96))
705 }
706
707 // panics-doc-ok (transitive via check_if_initialized)
708 /// Finds the maximum trade size that produces a target slippage (including fees).
709 ///
710 /// Uses binary search to find the largest trade size that results in slippage
711 /// at or below the target. The method iteratively simulates swaps at different
712 /// sizes until it converges to the optimal size within the specified tolerance.
713 ///
714 /// # Returns
715 /// The maximum trade size (U256) that produces the target slippage
716 ///
717 /// # Errors
718 /// Returns error if:
719 /// - Impact is zero or exceeds 100% (10000 bps)
720 /// - Pool is not initialized
721 /// - Swap simulations fail
722 ///
723 /// # Panics
724 ///
725 /// Panics if pool is not initialized.
726 pub fn size_for_impact_bps(&self, impact_bps: u32, zero_for_one: bool) -> anyhow::Result<U256> {
727 let config = size_estimator::EstimationConfig::default();
728 size_estimator::size_for_impact_bps(self, impact_bps, zero_for_one, &config)
729 }
730
731 /// Finds the maximum trade size with search diagnostics.
732 /// This is the detailed version of [`Self::size_for_impact_bps`] that returns
733 /// extensive information about the search process.It is useful for debugging,
734 /// monitoring, and analyzing search behavior in production.
735 ///
736 /// # Returns
737 /// Detailed result with size and search diagnostics
738 ///
739 /// # Errors
740 /// Returns error if:
741 /// - Impact is zero or exceeds 100% (10000 bps)
742 /// - Pool is not initialized
743 /// - Swap simulations fail
744 pub fn size_for_impact_bps_detailed(
745 &self,
746 impact_bps: u32,
747 zero_for_one: bool,
748 ) -> anyhow::Result<size_estimator::SizeForImpactResult> {
749 let config = size_estimator::EstimationConfig::default();
750 size_estimator::size_for_impact_bps_detailed(self, impact_bps, zero_for_one, &config)
751 }
752
753 /// Validates that the price limit is in the correct direction for the swap.
754 ///
755 /// # Errors
756 /// Returns error if price limit violates swap direction constraints.
757 fn validate_price_limit(
758 &self,
759 limit_price_sqrt: U160,
760 zero_for_one: bool,
761 ) -> anyhow::Result<()> {
762 if zero_for_one {
763 // Swapping token0 for token1: price must decrease
764 if limit_price_sqrt >= self.state.price_sqrt_ratio_x96 {
765 anyhow::bail!("Price limit must be less than current price for zero_for_one swaps");
766 }
767 } else {
768 // Swapping token1 for token0: price must increase
769 if limit_price_sqrt <= self.state.price_sqrt_ratio_x96 {
770 anyhow::bail!(
771 "Price limit must be greater than current price for one_for_zero swaps"
772 );
773 }
774 }
775
776 Ok(())
777 }
778
779 /// Processes a mint (liquidity add) event from historical data.
780 ///
781 /// Updates pool state when liquidity is added to a position, validates ticks,
782 /// and delegates to internal liquidity management methods.
783 ///
784 /// # Errors
785 ///
786 /// This function returns an error if:
787 /// - Pool is not initialized.
788 /// - Tick range is invalid or not properly spaced.
789 /// - Position updates fail.
790 pub fn process_mint(&mut self, update: &PoolLiquidityUpdate) -> anyhow::Result<()> {
791 self.check_if_initialized();
792
793 if self.check_if_already_processed(update.block, update.transaction_index, update.log_index)
794 {
795 return Ok(());
796 }
797
798 self.validate_ticks(update.tick_lower, update.tick_upper)?;
799 self.add_liquidity(
800 &update.owner,
801 update.tick_lower,
802 update.tick_upper,
803 update.position_liquidity,
804 update.amount0,
805 update.amount1,
806 )?;
807
808 self.analytics.total_mints += 1;
809 self.last_processed_event = Some(BlockPosition::new(
810 update.block,
811 update.transaction_hash.clone(),
812 update.transaction_index,
813 update.log_index,
814 ));
815 self.update_reporter_if_enabled(update.block);
816 self.update_liquidity_analytics();
817
818 Ok(())
819 }
820
821 /// Internal helper to add liquidity to a position.
822 ///
823 /// Updates position state, tracks deposited amounts, and manages tick maps.
824 /// Called by both historical event processing and simulated operations.
825 fn add_liquidity(
826 &mut self,
827 owner: &Address,
828 tick_lower: i32,
829 tick_upper: i32,
830 liquidity: u128,
831 amount0: U256,
832 amount1: U256,
833 ) -> anyhow::Result<()> {
834 let liquidity_delta = i128::try_from(liquidity)
835 .map_err(|_| anyhow::anyhow!("Liquidity {liquidity} exceeds i128::MAX"))?;
836 self.update_position(
837 owner,
838 tick_lower,
839 tick_upper,
840 liquidity_delta,
841 amount0,
842 amount1,
843 )?;
844
845 // Track deposited amounts
846 self.analytics.total_amount0_deposited += amount0;
847 self.analytics.total_amount1_deposited += amount1;
848
849 Ok(())
850 }
851
852 // panics-doc-ok (transitive via check_if_initialized)
853 /// Executes a simulated mint (liquidity addition) operation.
854 ///
855 /// Calculates required token amounts for the specified liquidity amount,
856 /// updates pool state, and returns the resulting mint event.
857 ///
858 /// # Errors
859 ///
860 /// This function returns an error if:
861 /// - Pool is not initialized.
862 /// - Tick range is invalid.
863 /// - Amount calculations fail.
864 ///
865 /// # Panics
866 ///
867 /// Panics if the current sqrt price has not been initialized.
868 pub fn execute_mint(
869 &mut self,
870 recipient: Address,
871 block: BlockPosition,
872 tick_lower: i32,
873 tick_upper: i32,
874 liquidity: u128,
875 ) -> anyhow::Result<PoolLiquidityUpdate> {
876 self.check_if_initialized();
877 self.validate_ticks(tick_lower, tick_upper)?;
878 let (amount0, amount1) = get_amounts_for_liquidity(
879 self.state.price_sqrt_ratio_x96,
880 tick_lower,
881 tick_upper,
882 liquidity,
883 true,
884 );
885 self.add_liquidity(
886 &recipient, tick_lower, tick_upper, liquidity, amount0, amount1,
887 )?;
888
889 self.analytics.total_mints += 1;
890 let event = PoolLiquidityUpdate::new(
891 self.pool.chain.clone(),
892 self.pool.dex.clone(),
893 self.pool.instrument_id,
894 self.pool.pool_identifier,
895 PoolLiquidityUpdateType::Mint,
896 block.number,
897 block.transaction_hash,
898 block.transaction_index,
899 block.log_index,
900 None,
901 recipient,
902 liquidity,
903 amount0,
904 amount1,
905 tick_lower,
906 tick_upper,
907 None,
908 );
909
910 Ok(event)
911 }
912
913 /// Processes a burn (liquidity removal) event from historical data.
914 ///
915 /// Updates pool state when liquidity is removed from a position. Uses negative
916 /// liquidity delta to reduce the position size and tracks withdrawn amounts.
917 ///
918 /// # Errors
919 ///
920 /// This function returns an error if:
921 /// - Pool is not initialized.
922 /// - Tick range is invalid.
923 /// - Position updates fail.
924 pub fn process_burn(&mut self, update: &PoolLiquidityUpdate) -> anyhow::Result<()> {
925 self.check_if_initialized();
926
927 if self.check_if_already_processed(update.block, update.transaction_index, update.log_index)
928 {
929 return Ok(());
930 }
931 self.validate_ticks(update.tick_lower, update.tick_upper)?;
932
933 // Update the position with a negative liquidity delta for the burn
934 let liquidity_delta = i128::try_from(update.position_liquidity).map_err(|_| {
935 anyhow::anyhow!("Liquidity {} exceeds i128::MAX", update.position_liquidity)
936 })?;
937 self.update_position(
938 &update.owner,
939 update.tick_lower,
940 update.tick_upper,
941 -liquidity_delta,
942 update.amount0,
943 update.amount1,
944 )?;
945
946 self.analytics.total_burns += 1;
947 self.last_processed_event = Some(BlockPosition::new(
948 update.block,
949 update.transaction_hash.clone(),
950 update.transaction_index,
951 update.log_index,
952 ));
953 self.update_reporter_if_enabled(update.block);
954 self.update_liquidity_analytics();
955
956 Ok(())
957 }
958
959 // panics-doc-ok (transitive via check_if_initialized)
960 /// Executes a simulated burn (liquidity removal) operation.
961 ///
962 /// Calculates token amounts that would be withdrawn for the specified liquidity,
963 /// updates pool state, and returns the resulting burn event.
964 ///
965 /// # Errors
966 ///
967 /// This function returns an error if:
968 /// - Pool is not initialized.
969 /// - Tick range is invalid.
970 /// - Amount calculations fail.
971 /// - Insufficient liquidity in position.
972 ///
973 /// # Panics
974 ///
975 /// Panics if the current sqrt price has not been initialized.
976 pub fn execute_burn(
977 &mut self,
978 recipient: Address,
979 block: BlockPosition,
980 tick_lower: i32,
981 tick_upper: i32,
982 liquidity: u128,
983 ) -> anyhow::Result<PoolLiquidityUpdate> {
984 self.check_if_initialized();
985 self.validate_ticks(tick_lower, tick_upper)?;
986 let (amount0, amount1) = get_amounts_for_liquidity(
987 self.state.price_sqrt_ratio_x96,
988 tick_lower,
989 tick_upper,
990 liquidity,
991 false,
992 );
993
994 // Update the position with a negative liquidity delta for the burn
995 let liquidity_delta = i128::try_from(liquidity)
996 .map_err(|_| anyhow::anyhow!("Liquidity {liquidity} exceeds i128::MAX"))?;
997 self.update_position(
998 &recipient,
999 tick_lower,
1000 tick_upper,
1001 -liquidity_delta,
1002 amount0,
1003 amount1,
1004 )?;
1005
1006 self.analytics.total_burns += 1;
1007 let event = PoolLiquidityUpdate::new(
1008 self.pool.chain.clone(),
1009 self.pool.dex.clone(),
1010 self.pool.instrument_id,
1011 self.pool.pool_identifier,
1012 PoolLiquidityUpdateType::Burn,
1013 block.number,
1014 block.transaction_hash,
1015 block.transaction_index,
1016 block.log_index,
1017 None,
1018 recipient,
1019 liquidity,
1020 amount0,
1021 amount1,
1022 tick_lower,
1023 tick_upper,
1024 None,
1025 );
1026
1027 Ok(event)
1028 }
1029
1030 /// Processes a fee collect event from historical data.
1031 ///
1032 /// Updates position state when accumulated fees are collected. Finds the
1033 /// position and delegates fee collection to the position object.
1034 ///
1035 /// Note: Tick validation is intentionally skipped to match Uniswap V3 behavior.
1036 /// Invalid positions have no fees to collect, so they're silently ignored.
1037 ///
1038 /// # Errors
1039 ///
1040 /// This function returns an error if:
1041 /// - Pool is not initialized.
1042 pub fn process_collect(&mut self, collect: &PoolFeeCollect) -> anyhow::Result<()> {
1043 self.check_if_initialized();
1044
1045 if self.check_if_already_processed(
1046 collect.block,
1047 collect.transaction_index,
1048 collect.log_index,
1049 ) {
1050 return Ok(());
1051 }
1052 let position_key =
1053 PoolPosition::get_position_key(&collect.owner, collect.tick_lower, collect.tick_upper);
1054
1055 if let Some(position) = self.positions.get_mut(&position_key) {
1056 position.collect_fees(collect.amount0, collect.amount1);
1057 }
1058
1059 // Cleanup position if it became empty after collecting all fees
1060 self.cleanup_position_if_empty(&position_key);
1061
1062 self.analytics.total_amount0_collected += U256::from(collect.amount0);
1063 self.analytics.total_amount1_collected += U256::from(collect.amount1);
1064
1065 self.analytics.total_fee_collects += 1;
1066 self.last_processed_event = Some(BlockPosition::new(
1067 collect.block,
1068 collect.transaction_hash.clone(),
1069 collect.transaction_index,
1070 collect.log_index,
1071 ));
1072 self.update_reporter_if_enabled(collect.block);
1073 self.update_liquidity_analytics();
1074
1075 Ok(())
1076 }
1077
1078 // panics-doc-ok (transitive via check_if_initialized)
1079 /// Processes a flash loan event from historical data.
1080 ///
1081 /// # Errors
1082 ///
1083 /// Returns an error if:
1084 /// - Pool has no active liquidity.
1085 /// - Fee growth arithmetic overflows.
1086 ///
1087 /// # Panics
1088 ///
1089 /// Panics if the pool has not been initialized.
1090 pub fn process_flash(&mut self, flash: &PoolFlash) -> anyhow::Result<()> {
1091 self.check_if_initialized();
1092
1093 if self.check_if_already_processed(flash.block, flash.transaction_index, flash.log_index) {
1094 return Ok(());
1095 }
1096
1097 self.update_flash_state(flash.paid0, flash.paid1)?;
1098
1099 self.analytics.total_flashes += 1;
1100 self.last_processed_event = Some(BlockPosition::new(
1101 flash.block,
1102 flash.transaction_hash.clone(),
1103 flash.transaction_index,
1104 flash.log_index,
1105 ));
1106 self.update_reporter_if_enabled(flash.block);
1107 self.update_liquidity_analytics();
1108
1109 Ok(())
1110 }
1111
1112 /// Executes a simulated flash loan operation and returns the resulting event.
1113 ///
1114 /// # Errors
1115 ///
1116 /// Returns an error if:
1117 /// - Mathematical operations overflow when calculating fees.
1118 /// - Pool has no active liquidity.
1119 /// - Fee growth arithmetic overflows.
1120 ///
1121 /// # Panics
1122 ///
1123 /// Panics if:
1124 /// - Pool is not initialized
1125 /// - Pool fee is not set
1126 pub fn execute_flash(
1127 &mut self,
1128 sender: Address,
1129 recipient: Address,
1130 block: BlockPosition,
1131 amount0: U256,
1132 amount1: U256,
1133 ) -> anyhow::Result<PoolFlash> {
1134 self.check_if_initialized();
1135 let fee_tier = self.pool.fee.expect("Pool fee should be initialized");
1136
1137 // Calculate fees or paid0/paid1
1138 let paid0 = if amount0 > U256::ZERO {
1139 FullMath::mul_div_rounding_up(amount0, U256::from(fee_tier), U256::from(1_000_000))?
1140 } else {
1141 U256::ZERO
1142 };
1143
1144 let paid1 = if amount1 > U256::ZERO {
1145 FullMath::mul_div_rounding_up(amount1, U256::from(fee_tier), U256::from(1_000_000))?
1146 } else {
1147 U256::ZERO
1148 };
1149
1150 self.update_flash_state(paid0, paid1)?;
1151 self.analytics.total_flashes += 1;
1152
1153 let flash_event = PoolFlash::new(
1154 self.pool.chain.clone(),
1155 self.pool.dex.clone(),
1156 self.pool.instrument_id,
1157 self.pool.pool_identifier,
1158 block.number,
1159 block.transaction_hash,
1160 block.transaction_index,
1161 block.log_index,
1162 None,
1163 sender,
1164 recipient,
1165 amount0,
1166 amount1,
1167 paid0,
1168 paid1,
1169 );
1170
1171 Ok(flash_event)
1172 }
1173
1174 /// Core flash loan state update logic.
1175 ///
1176 /// # Errors
1177 ///
1178 /// Returns error if:
1179 /// - No active liquidity in pool
1180 /// - Fee growth arithmetic overflows
1181 fn update_flash_state(&mut self, paid0: U256, paid1: U256) -> anyhow::Result<()> {
1182 let liquidity = self.tick_map.liquidity;
1183 if liquidity == 0 {
1184 anyhow::bail!("No liquidity")
1185 }
1186
1187 let fee_protocol_0 = self.state.fee_protocol % 16;
1188 let fee_protocol_1 = self.state.fee_protocol >> 4;
1189
1190 // Process token0 fees
1191 if paid0 > U256::ZERO {
1192 let protocol_fee_0 = if fee_protocol_0 > 0 {
1193 paid0 / U256::from(fee_protocol_0)
1194 } else {
1195 U256::ZERO
1196 };
1197
1198 if protocol_fee_0 > U256::ZERO {
1199 self.state.protocol_fees_token0 += protocol_fee_0;
1200 }
1201
1202 let lp_fee_0 = paid0 - protocol_fee_0;
1203 let delta = FullMath::mul_div(lp_fee_0, Q128, U256::from(liquidity))?;
1204 self.state.fee_growth_global_0 += delta;
1205 }
1206
1207 // Process token1 fees
1208 if paid1 > U256::ZERO {
1209 let protocol_fee_1 = if fee_protocol_1 > 0 {
1210 paid1 / U256::from(fee_protocol_1)
1211 } else {
1212 U256::ZERO
1213 };
1214
1215 if protocol_fee_1 > U256::ZERO {
1216 self.state.protocol_fees_token1 += protocol_fee_1;
1217 }
1218
1219 let lp_fee_1 = paid1 - protocol_fee_1;
1220 let delta = FullMath::mul_div(lp_fee_1, Q128, U256::from(liquidity))?;
1221 self.state.fee_growth_global_1 += delta;
1222 }
1223
1224 Ok(())
1225 }
1226
1227 /// Updates position state and tick maps when liquidity changes.
1228 ///
1229 /// Core internal method that handles position updates for both mints and burns.
1230 /// Updates tick maps, position tracking, fee growth, and active liquidity.
1231 fn update_position(
1232 &mut self,
1233 owner: &Address,
1234 tick_lower: i32,
1235 tick_upper: i32,
1236 liquidity_delta: i128,
1237 amount0: U256,
1238 amount1: U256,
1239 ) -> anyhow::Result<()> {
1240 let current_tick = self.state.current_tick;
1241 let position_key = PoolPosition::get_position_key(owner, tick_lower, tick_upper);
1242 let position = self
1243 .positions
1244 .entry(position_key)
1245 .or_insert(PoolPosition::new(*owner, tick_lower, tick_upper, 0));
1246
1247 // Only validate when burning (negative liquidity_delta)
1248 if liquidity_delta < 0 {
1249 let burn_amount = liquidity_delta.unsigned_abs();
1250 if position.liquidity < burn_amount {
1251 anyhow::bail!(
1252 "Position liquidity {} is less than the requested burn amount of {}",
1253 position.liquidity,
1254 burn_amount
1255 );
1256 }
1257 }
1258
1259 // Update tickmaps.
1260 let flipped_lower = self.tick_map.update(
1261 tick_lower,
1262 current_tick,
1263 liquidity_delta,
1264 false,
1265 self.state.fee_growth_global_0,
1266 self.state.fee_growth_global_1,
1267 );
1268 let flipped_upper = self.tick_map.update(
1269 tick_upper,
1270 current_tick,
1271 liquidity_delta,
1272 true,
1273 self.state.fee_growth_global_0,
1274 self.state.fee_growth_global_1,
1275 );
1276
1277 let (fee_growth_inside_0, fee_growth_inside_1) = self.tick_map.get_fee_growth_inside(
1278 tick_lower,
1279 tick_upper,
1280 current_tick,
1281 self.state.fee_growth_global_0,
1282 self.state.fee_growth_global_1,
1283 );
1284 position.update_liquidity(liquidity_delta);
1285 position.update_fees(fee_growth_inside_0, fee_growth_inside_1);
1286 position.update_amounts(liquidity_delta, amount0, amount1);
1287
1288 // Update active liquidity if this position spans the current tick
1289 if tick_lower <= current_tick && current_tick < tick_upper {
1290 self.tick_map.liquidity = liquidity_math_add(self.tick_map.liquidity, liquidity_delta);
1291 }
1292
1293 // Clear the ticks if they are flipped and burned
1294 if liquidity_delta < 0 && flipped_lower {
1295 self.tick_map.clear(tick_lower);
1296 }
1297
1298 if liquidity_delta < 0 && flipped_upper {
1299 self.tick_map.clear(tick_upper);
1300 }
1301
1302 Ok(())
1303 }
1304
1305 /// Removes position from tracking if it's completely empty.
1306 ///
1307 /// This prevents accumulation of positions in the memory that are not used anymore.
1308 fn cleanup_position_if_empty(&mut self, position_key: &str) {
1309 if let Some(position) = self.positions.get(position_key)
1310 && position.is_empty()
1311 {
1312 log::debug!(
1313 "CLEANING UP EMPTY POSITION: owner={}, ticks=[{}, {}]",
1314 position.owner,
1315 position.tick_lower,
1316 position.tick_upper,
1317 );
1318 self.positions.remove(position_key);
1319 }
1320 }
1321
1322 /// Calculates the liquidity utilization rate for the pool.
1323 ///
1324 /// The utilization rate measures what percentage of total deployed liquidity
1325 /// is currently active (in-range and earning fees) at the current price tick.
1326 #[must_use]
1327 pub fn liquidity_utilization_rate(&self) -> f64 {
1328 const PRECISION: u32 = 1_000_000; // 6 decimal places
1329
1330 let total_liquidity = self.get_total_liquidity();
1331 let active_liquidity = self.get_active_liquidity();
1332
1333 if total_liquidity == U256::ZERO {
1334 return 0.0;
1335 }
1336 let ratio = FullMath::mul_div(
1337 U256::from(active_liquidity),
1338 U256::from(PRECISION),
1339 total_liquidity,
1340 )
1341 .unwrap_or(U256::ZERO);
1342
1343 // Safe to cast to u64: Since active_liquidity <= total_liquidity,
1344 // the ratio is guaranteed to be <= PRECISION (1_000_000), which fits in u64
1345 ratio.to::<u64>() as f64 / f64::from(PRECISION)
1346 }
1347
1348 /// Validates tick range for position operations.
1349 ///
1350 /// Ensures ticks are properly ordered, aligned to tick spacing, and within
1351 /// valid bounds. Used by all position-related operations.
1352 ///
1353 /// # Errors
1354 ///
1355 /// This function returns an error if:
1356 /// - `tick_lower >= tick_upper` (invalid range).
1357 /// - Ticks are not multiples of pool's tick spacing.
1358 /// - Ticks are outside `MIN_TICK/MAX_TICK` bounds.
1359 fn validate_ticks(&self, tick_lower: i32, tick_upper: i32) -> anyhow::Result<()> {
1360 if tick_lower >= tick_upper {
1361 anyhow::bail!("Invalid tick range: {tick_lower} >= {tick_upper}")
1362 }
1363
1364 if tick_lower % self.pool.tick_spacing.unwrap() as i32 != 0
1365 || tick_upper % self.pool.tick_spacing.unwrap() as i32 != 0
1366 {
1367 anyhow::bail!(
1368 "Ticks {tick_lower} and {tick_upper} must be multiples of the tick spacing"
1369 )
1370 }
1371
1372 if tick_lower < PoolTick::MIN_TICK || tick_upper > PoolTick::MAX_TICK {
1373 anyhow::bail!("Invalid tick bounds for {tick_lower} and {tick_upper}");
1374 }
1375 Ok(())
1376 }
1377
1378 /// Updates all liquidity analytics.
1379 fn update_liquidity_analytics(&mut self) {
1380 self.analytics.liquidity_utilization_rate = self.liquidity_utilization_rate();
1381 }
1382
1383 /// Returns the pool's active liquidity tracked by the tick map.
1384 ///
1385 /// This represents the effective liquidity available for trading at the current price.
1386 /// The tick map maintains this value efficiently by updating it during tick crossings
1387 /// as the price moves through different ranges.
1388 ///
1389 /// # Returns
1390 /// The active liquidity (u128) at the current tick from the tick map
1391 #[must_use]
1392 pub fn get_active_liquidity(&self) -> u128 {
1393 self.tick_map.liquidity
1394 }
1395
1396 /// Calculates total liquidity by summing all individual positions at the current tick.
1397 ///
1398 /// This computes liquidity by iterating through all positions and summing those that
1399 /// span the current tick. Unlike [`Self::get_active_liquidity`], which returns the maintained
1400 /// tick map value, this method performs a fresh calculation from position data.
1401 #[must_use]
1402 pub fn get_total_liquidity_from_active_positions(&self) -> u128 {
1403 self.positions
1404 .values()
1405 .filter(|position| {
1406 position.liquidity > 0
1407 && position.tick_lower <= self.state.current_tick
1408 && self.state.current_tick < position.tick_upper
1409 })
1410 .map(|position| position.liquidity)
1411 .sum()
1412 }
1413
1414 /// Calculates total liquidity across all positions, regardless of range status.
1415 #[must_use]
1416 pub fn get_total_liquidity(&self) -> U256 {
1417 self.positions
1418 .values()
1419 .map(|position| U256::from(position.liquidity))
1420 .fold(U256::ZERO, |acc, liq| acc + liq)
1421 }
1422
1423 /// Restores the profiler state from a saved snapshot.
1424 ///
1425 /// This method allows resuming profiling from a previously saved state,
1426 /// enabling incremental processing without reprocessing all historical events.
1427 ///
1428 /// # Errors
1429 ///
1430 /// Returns an error if:
1431 /// - Tick insertion into the tick map fails.
1432 ///
1433 /// # Panics
1434 ///
1435 /// Panics if the pool's tick spacing is not set.
1436 pub fn restore_from_snapshot(&mut self, snapshot: PoolSnapshot) -> anyhow::Result<()> {
1437 let liquidity = snapshot.state.liquidity;
1438
1439 // Restore state
1440 self.state = snapshot.state;
1441
1442 // Restore analytics (skip duration fields as they're debug-only)
1443 self.analytics.total_amount0_deposited = snapshot.analytics.total_amount0_deposited;
1444 self.analytics.total_amount1_deposited = snapshot.analytics.total_amount1_deposited;
1445 self.analytics.total_amount0_collected = snapshot.analytics.total_amount0_collected;
1446 self.analytics.total_amount1_collected = snapshot.analytics.total_amount1_collected;
1447 self.analytics.total_swaps = snapshot.analytics.total_swaps;
1448 self.analytics.total_mints = snapshot.analytics.total_mints;
1449 self.analytics.total_burns = snapshot.analytics.total_burns;
1450 self.analytics.total_fee_collects = snapshot.analytics.total_fee_collects;
1451 self.analytics.total_flashes = snapshot.analytics.total_flashes;
1452
1453 // Rebuild positions AHashMap
1454 self.positions.clear();
1455 for position in snapshot.positions {
1456 let key = PoolPosition::get_position_key(
1457 &position.owner,
1458 position.tick_lower,
1459 position.tick_upper,
1460 );
1461 self.positions.insert(key, position);
1462 }
1463
1464 // Rebuild tick_map
1465 self.tick_map = TickMap::new(
1466 self.pool
1467 .tick_spacing
1468 .expect("Pool tick spacing must be set"),
1469 );
1470
1471 for tick in snapshot.ticks {
1472 self.tick_map.restore_tick(tick);
1473 }
1474
1475 // Restore active liquidity
1476 self.tick_map.liquidity = liquidity;
1477
1478 // Set last processed event
1479 self.last_processed_event = Some(snapshot.block_position);
1480
1481 // Mark as initialized
1482 self.is_initialized = true;
1483
1484 // Recalculate analytics
1485 self.update_liquidity_analytics();
1486
1487 Ok(())
1488 }
1489
1490 /// Gets a list of all initialized tick values.
1491 ///
1492 /// Returns tick values that have been initialized (have liquidity positions).
1493 /// Useful for understanding the liquidity distribution across price ranges.
1494 #[must_use]
1495 pub fn get_active_tick_values(&self) -> Vec<i32> {
1496 self.tick_map
1497 .get_all_ticks()
1498 .iter()
1499 .filter(|(_, tick)| self.tick_map.is_tick_initialized(tick.value))
1500 .map(|(tick_value, _)| *tick_value)
1501 .collect()
1502 }
1503
1504 /// Gets the number of active ticks.
1505 #[must_use]
1506 pub fn get_active_tick_count(&self) -> usize {
1507 self.tick_map.active_tick_count()
1508 }
1509
1510 /// Gets tick information for a specific tick value.
1511 ///
1512 /// Returns the tick data structure containing liquidity and fee information
1513 /// for the specified tick, if it exists.
1514 #[must_use]
1515 pub fn get_tick(&self, tick: i32) -> Option<&PoolTick> {
1516 self.tick_map.get_tick(tick)
1517 }
1518
1519 /// Gets the current tick position of the pool.
1520 ///
1521 /// Returns the tick that corresponds to the current pool price.
1522 /// The pool must be initialized before calling this method.
1523 #[must_use]
1524 pub fn get_current_tick(&self) -> i32 {
1525 self.state.current_tick
1526 }
1527
1528 /// Gets the total number of ticks tracked by the tick map.
1529 ///
1530 /// Returns count of all ticks that have ever been initialized,
1531 /// including those that may no longer have active liquidity.
1532 ///
1533 /// # Returns
1534 /// Total tick count in the tick map
1535 #[must_use]
1536 pub fn get_total_tick_count(&self) -> usize {
1537 self.tick_map.total_tick_count()
1538 }
1539
1540 /// Gets position information for a specific owner and tick range.
1541 ///
1542 /// Looks up a position by its unique key (owner + tick range) and returns
1543 /// the position data if it exists.
1544 #[must_use]
1545 pub fn get_position(
1546 &self,
1547 owner: &Address,
1548 tick_lower: i32,
1549 tick_upper: i32,
1550 ) -> Option<&PoolPosition> {
1551 let position_key = PoolPosition::get_position_key(owner, tick_lower, tick_upper);
1552 self.positions.get(&position_key)
1553 }
1554
1555 /// Returns a list of all currently active positions.
1556 ///
1557 /// Active positions are those with liquidity > 0 whose tick range includes
1558 /// the current pool tick, meaning they have tokens actively deployed in the pool
1559 /// and are earning fees from trades at the current price.
1560 ///
1561 /// # Returns
1562 ///
1563 /// A vector of references to active [`PoolPosition`] objects.
1564 #[must_use]
1565 pub fn get_active_positions(&self) -> Vec<&PoolPosition> {
1566 self.positions
1567 .values()
1568 .filter(|position| {
1569 let current_tick = self.get_current_tick();
1570 position.liquidity > 0
1571 && position.tick_lower <= current_tick
1572 && current_tick < position.tick_upper
1573 })
1574 .collect()
1575 }
1576
1577 /// Returns a list of all positions tracked by the profiler.
1578 ///
1579 /// This includes both active and inactive positions, regardless of their
1580 /// liquidity or tick range relative to the current pool tick.
1581 ///
1582 /// # Returns
1583 ///
1584 /// A vector of references to all [`PoolPosition`] objects.
1585 #[must_use]
1586 pub fn get_all_positions(&self) -> Vec<&PoolPosition> {
1587 self.positions.values().collect()
1588 }
1589
1590 /// Returns position keys for all tracked positions.
1591 #[must_use]
1592 pub fn get_all_position_keys(&self) -> Vec<(Address, i32, i32)> {
1593 self.get_all_positions()
1594 .iter()
1595 .map(|position| (position.owner, position.tick_lower, position.tick_upper))
1596 .collect()
1597 }
1598
1599 /// Extracts a complete snapshot of the current pool state.
1600 ///
1601 /// Extracts and bundles the complete pool state including global variables,
1602 /// all liquidity positions, and the full tick distribution into a portable
1603 /// [`PoolSnapshot`] structure. This snapshot can be serialized, persisted
1604 /// to database, or used to restore pool state later.
1605 ///
1606 /// # Panics
1607 ///
1608 /// Panics if no events have been processed yet.
1609 #[must_use]
1610 pub fn extract_snapshot(&self) -> PoolSnapshot {
1611 let positions: Vec<_> = self.positions.values().cloned().collect();
1612 let ticks: Vec<_> = self.tick_map.get_all_ticks().values().copied().collect();
1613
1614 let mut state = self.state.clone();
1615 state.liquidity = self.tick_map.liquidity;
1616
1617 PoolSnapshot::new(
1618 self.pool.instrument_id,
1619 state,
1620 positions,
1621 ticks,
1622 self.analytics.clone(),
1623 self.last_processed_event
1624 .clone()
1625 .expect("No events processed yet"),
1626 )
1627 }
1628
1629 /// Gets the count of positions that are currently active.
1630 ///
1631 /// Active positions are those with liquidity > 0 and whose tick range
1632 /// includes the current pool tick (meaning they have tokens in the pool).
1633 #[must_use]
1634 pub fn get_total_active_positions(&self) -> usize {
1635 self.positions
1636 .iter()
1637 .filter(|(_, position)| {
1638 let current_tick = self.get_current_tick();
1639 position.liquidity > 0
1640 && position.tick_lower <= current_tick
1641 && current_tick < position.tick_upper
1642 })
1643 .count()
1644 }
1645
1646 /// Gets the count of positions that are currently inactive.
1647 ///
1648 /// Inactive positions are those that exist but don't span the current tick,
1649 /// meaning their liquidity is entirely in one token or the other.
1650 #[must_use]
1651 pub fn get_total_inactive_positions(&self) -> usize {
1652 self.positions.len() - self.get_total_active_positions()
1653 }
1654
1655 /// Estimates the total amount of token0 in the pool.
1656 ///
1657 /// Calculates token0 balance by summing:
1658 /// - Token0 amounts from all active liquidity positions
1659 /// - Accumulated trading fees (approximated from fee growth)
1660 /// - Protocol fees collected
1661 #[must_use]
1662 pub fn estimate_balance_of_token0(&self) -> U256 {
1663 let mut total_amount0 = U256::ZERO;
1664 let current_sqrt_price = self.state.price_sqrt_ratio_x96;
1665 let current_tick = self.state.current_tick;
1666 let mut total_fees_0_collected: u128 = 0;
1667
1668 // 1. Calculate token0 from active liquidity positions
1669 for position in self.positions.values() {
1670 if position.liquidity > 0 {
1671 if position.tick_upper <= current_tick {
1672 // Position is below current price - no token0
1673 continue;
1674 } else if position.tick_lower > current_tick {
1675 // Position is above current price - all token0
1676 let sqrt_ratio_a = get_sqrt_ratio_at_tick(position.tick_lower);
1677 let sqrt_ratio_b = get_sqrt_ratio_at_tick(position.tick_upper);
1678 let amount0 =
1679 get_amount0_delta(sqrt_ratio_a, sqrt_ratio_b, position.liquidity, true);
1680 total_amount0 += amount0;
1681 } else {
1682 // Position is active - token0 from current price to upper tick
1683 let sqrt_ratio_upper = get_sqrt_ratio_at_tick(position.tick_upper);
1684 let amount0 = get_amount0_delta(
1685 current_sqrt_price,
1686 sqrt_ratio_upper,
1687 position.liquidity,
1688 true,
1689 );
1690 total_amount0 += amount0;
1691 }
1692 }
1693
1694 total_fees_0_collected += position.total_amount0_collected;
1695 }
1696
1697 // 2. Add accumulated swap fees (fee_growth_global represents total fees accumulated)
1698 // Note: In a real pool, fees are distributed as liquidity, but for balance estimation
1699 // we can use a simplified approach by converting fee growth to token amounts
1700 let fee_growth_0 = self.state.fee_growth_global_0;
1701 if fee_growth_0 > U256::ZERO {
1702 // Convert fee growth to actual token amount using FullMath for precision
1703 // Fee growth is in Q128.128 format, so we need to scale it properly
1704 let active_liquidity = self.get_active_liquidity();
1705 if active_liquidity > 0 {
1706 // fee_growth_global is fees per unit of liquidity in Q128.128
1707 // To get total fees: mul_div(fee_growth, liquidity, 2^128)
1708 if let Ok(total_fees_0) =
1709 FullMath::mul_div(fee_growth_0, U256::from(active_liquidity), Q128)
1710 {
1711 total_amount0 += total_fees_0;
1712 }
1713 }
1714 }
1715
1716 let total_fees_0_left = fee_growth_0 - U256::from(total_fees_0_collected);
1717
1718 // 4. Add protocol fees
1719 total_amount0 += self.state.protocol_fees_token0;
1720
1721 total_amount0 + total_fees_0_left
1722 }
1723
1724 /// Estimates the total amount of token1 in the pool.
1725 ///
1726 /// Calculates token1 balance by summing:
1727 /// - Token1 amounts from all active liquidity positions
1728 /// - Accumulated trading fees (approximated from fee growth)
1729 /// - Protocol fees collected
1730 #[must_use]
1731 pub fn estimate_balance_of_token1(&self) -> U256 {
1732 let mut total_amount1 = U256::ZERO;
1733 let current_sqrt_price = self.state.price_sqrt_ratio_x96;
1734 let current_tick = self.state.current_tick;
1735 let mut total_fees_1_collected: u128 = 0;
1736
1737 // 1. Calculate token1 from active liquidity positions
1738 for position in self.positions.values() {
1739 if position.liquidity > 0 {
1740 if position.tick_lower > current_tick {
1741 // Position is above current price - no token1
1742 continue;
1743 } else if position.tick_upper <= current_tick {
1744 // Position is below current price - all token1
1745 let sqrt_ratio_a = get_sqrt_ratio_at_tick(position.tick_lower);
1746 let sqrt_ratio_b = get_sqrt_ratio_at_tick(position.tick_upper);
1747 let amount1 =
1748 get_amount1_delta(sqrt_ratio_a, sqrt_ratio_b, position.liquidity, true);
1749 total_amount1 += amount1;
1750 } else {
1751 // Position is active - token1 from lower tick to current price
1752 let sqrt_ratio_lower = get_sqrt_ratio_at_tick(position.tick_lower);
1753 let amount1 = get_amount1_delta(
1754 sqrt_ratio_lower,
1755 current_sqrt_price,
1756 position.liquidity,
1757 true,
1758 );
1759 total_amount1 += amount1;
1760 }
1761 }
1762
1763 // Sum collected fees
1764 total_fees_1_collected += position.total_amount1_collected;
1765 }
1766
1767 // 2. Add accumulated swap fees for token1
1768 let fee_growth_1 = self.state.fee_growth_global_1;
1769 if fee_growth_1 > U256::ZERO {
1770 let active_liquidity = self.get_active_liquidity();
1771 if active_liquidity > 0 {
1772 // Convert fee growth to actual token amount using FullMath
1773 if let Ok(total_fees_1) =
1774 FullMath::mul_div(fee_growth_1, U256::from(active_liquidity), Q128)
1775 {
1776 total_amount1 += total_fees_1;
1777 }
1778 }
1779 }
1780
1781 let total_fees_1_left = fee_growth_1 - U256::from(total_fees_1_collected);
1782
1783 // 4. Add protocol fees
1784 total_amount1 += self.state.protocol_fees_token1;
1785
1786 total_amount1 + total_fees_1_left
1787 }
1788
1789 /// Sets the global fee growth for both tokens.
1790 ///
1791 /// This is primarily used for testing to simulate specific fee growth scenarios.
1792 /// In production, fee growth is updated through swap operations.
1793 ///
1794 /// # Arguments
1795 /// * `fee_growth_global_0` - New global fee growth for token0
1796 /// * `fee_growth_global_1` - New global fee growth for token1
1797 pub fn set_fee_growth_global(&mut self, fee_growth_global_0: U256, fee_growth_global_1: U256) {
1798 self.state.fee_growth_global_0 = fee_growth_global_0;
1799 self.state.fee_growth_global_1 = fee_growth_global_1;
1800 }
1801
1802 /// Returns the total number of events processed.
1803 #[must_use]
1804 pub fn get_total_events(&self) -> u64 {
1805 self.analytics.total_swaps
1806 + self.analytics.total_mints
1807 + self.analytics.total_burns
1808 + self.analytics.total_fee_collects
1809 + self.analytics.total_flashes
1810 }
1811
1812 /// Enables progress reporting for pool profiler event processing.
1813 ///
1814 /// When enabled, the profiler will automatically track and log progress
1815 /// as events are processed through the `process()` method.
1816 pub fn enable_reporting(&mut self, from_block: u64, total_blocks: u64, update_interval: u64) {
1817 self.reporter = Some(BlockchainSyncReporter::new(
1818 BlockchainSyncReportItems::PoolProfiling,
1819 from_block,
1820 total_blocks,
1821 update_interval,
1822 ));
1823 self.last_reported_block = from_block;
1824 }
1825
1826 /// Finalizes reporting and logs final statistics.
1827 ///
1828 /// Should be called after all events have been processed to output
1829 /// the final summary of the profiler bootstrap operation.
1830 pub fn finalize_reporting(&mut self) {
1831 if let Some(reporter) = &self.reporter {
1832 reporter.log_final_stats();
1833 }
1834 self.reporter = None;
1835 }
1836}