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