Skip to main content

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