From db224e35cb7a5e4cbdbb72fb88d1f898c1f9007f Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Wed, 22 Oct 2025 14:45:22 +0200 Subject: [PATCH 1/6] Clean up empty positions after collecting all fees in pool profiler --- .../model/src/defi/pool_analysis/profiler.rs | 20 ++++++ crates/model/src/defi/pool_analysis/tests.rs | 65 ++++++++----------- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/crates/model/src/defi/pool_analysis/profiler.rs b/crates/model/src/defi/pool_analysis/profiler.rs index 4fb8832b984a..282fa9d98202 100644 --- a/crates/model/src/defi/pool_analysis/profiler.rs +++ b/crates/model/src/defi/pool_analysis/profiler.rs @@ -977,6 +977,9 @@ impl PoolProfiler { position.collect_fees(collect.amount0, collect.amount1); } + // Cleanup position if it became empty after collecting all fees + self.cleanup_position_if_empty(&position_key); + self.analytics.total_amount0_collected += U256::from(collect.amount0); self.analytics.total_amount1_collected += U256::from(collect.amount1); @@ -1215,6 +1218,23 @@ impl PoolProfiler { Ok(()) } + /// Removes position from tracking if it's completely empty. + /// + /// This prevents accumulation of positions in the memory that are not used anymore. + fn cleanup_position_if_empty(&mut self, position_key: &str) { + if let Some(position) = self.positions.get(position_key) + && position.is_empty() + { + tracing::debug!( + "CLEANING UP EMPTY POSITION: owner={}, ticks=[{}, {}]", + position.owner, + position.tick_lower, + position.tick_upper, + ); + self.positions.remove(position_key); + } + } + /// Validates tick range for position operations. /// /// Ensures ticks are properly ordered, aligned to tick spacing, and within diff --git a/crates/model/src/defi/pool_analysis/tests.rs b/crates/model/src/defi/pool_analysis/tests.rs index 42a8798176b6..db0aef94e112 100644 --- a/crates/model/src/defi/pool_analysis/tests.rs +++ b/crates/model/src/defi/pool_analysis/tests.rs @@ -905,18 +905,15 @@ fn test_if_removing_of_liquidity_works_after_mint(mut uni_pool_profiler: PoolPro .process(&DexPoolData::FeeCollect(collect_event)) .unwrap(); - if let Some(position) = uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick) { - assert_eq!(position.liquidity, 0); - assert_eq!(position.total_amount0_deposited, 121); - assert_eq!(position.total_amount1_deposited, 0); - // Tokens are collected so we keep track of collects values and tokens_owned_* are zero - assert_eq!(position.total_amount0_collected, 120); - assert_eq!(position.total_amount1_collected, 0); - assert_eq!(position.tokens_owed_0, 0); - assert_eq!(position.tokens_owed_1, 0); - } else { - panic!("Position should exist"); - } + // After collect, position should be cleaned up since it's completely empty + assert!( + uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick).is_none(), + "Position should be cleaned up after collecting all fees" + ); + + // Verify position is no longer counted + assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); // Only init position + assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 0); // Cleaned up } #[rstest] @@ -1344,22 +1341,15 @@ fn test_mint_then_burning_and_collecting(mut uni_pool_profiler: PoolProfiler) { .process(&DexPoolData::FeeCollect(collect_event)) .unwrap(); - // One active(initial) and one inactive(this one which was minted and then burned) - assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); - assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 1); - - let position = uni_pool_profiler - .get_position(&lp_address(), lower_tick, upper_tick) - .expect("Position should exist"); + // After collect, position should be cleaned up since it's completely empty + assert!( + uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick).is_none(), + "Position should be cleaned up after collecting all fees" + ); - assert_eq!(position.liquidity, 0); - assert_eq!(position.tick_lower, lower_tick); - assert_eq!(position.tick_upper, upper_tick); - // Tokens owned zero, and collected have target values - assert_eq!(position.tokens_owed_0, 0); - assert_eq!(position.tokens_owed_1, 0); - assert_eq!(position.total_amount0_collected, 316); - assert_eq!(position.total_amount1_collected, 31); + // Verify position is no longer counted + assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); // Only init position + assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 0); // Cleaned up } // ---------- TEST MINTS BELOW CURRENT PRICE ---------- @@ -1455,18 +1445,15 @@ fn test_if_mint_below_current_price_works_after_burn_and_fee_collect( .process(&DexPoolData::FeeCollect(collect_event)) .unwrap(); - if let Some(position) = uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick) { - assert_eq!(position.liquidity, 0); - // Round up to 4 for minting(adding liquidity), and round down to 3 for removing liquidity - assert_eq!(position.total_amount0_deposited, 0); - assert_eq!(position.total_amount1_deposited, 4); - assert_eq!(position.tokens_owed_0, 0); - assert_eq!(position.tokens_owed_1, 0); - assert_eq!(position.total_amount0_collected, 0); - assert_eq!(position.total_amount1_collected, 3); - } else { - panic!("Position should exist"); - } + // After collect, position should be cleaned up since it's completely empty + assert!( + uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick).is_none(), + "Position should be cleaned up after collecting all fees" + ); + + // Verify position is no longer counted + assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); // Only init position + assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 0); // Cleaned up } #[rstest] From 78ea33f03883d2e941f4e530dcf8591e8fc88a61 Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Thu, 23 Oct 2025 11:01:21 +0200 Subject: [PATCH 2/6] Add `liquidity_utilization_rate` field to pool snapshot schema and database operations --- crates/adapters/blockchain/src/cache/database.rs | 8 ++++++-- schema/sql/tables.sql | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/adapters/blockchain/src/cache/database.rs b/crates/adapters/blockchain/src/cache/database.rs index 304fb3024b2e..12f2354ec980 100644 --- a/crates/adapters/blockchain/src/cache/database.rs +++ b/crates/adapters/blockchain/src/cache/database.rs @@ -1329,12 +1329,13 @@ impl BlockchainCacheDatabase { fee_growth_global_0, fee_growth_global_1, total_amount0_deposited, total_amount1_deposited, total_amount0_collected, total_amount1_collected, - total_swaps, total_mints, total_burns, total_fee_collects, total_flashes + total_swaps, total_mints, total_burns, total_fee_collects, total_flashes, + liquidity_utilization_rate ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8::U160, $9::U128, $10::U256, $11::U256, $12, $13::U256, $14::U256, $15::U256, $16::U256, $17::U256, $18::U256, - $19, $20, $21, $22, $23 + $19, $20, $21, $22, $23, $24 ) ON CONFLICT (chain_id, pool_address, block, transaction_index, log_index) DO NOTHING @@ -1363,6 +1364,7 @@ impl BlockchainCacheDatabase { .bind(snapshot.analytics.total_burns as i32) .bind(snapshot.analytics.total_fee_collects as i32) .bind(snapshot.analytics.total_flashes as i32) + .bind(snapshot.analytics.liquidity_utilization_rate) .execute(&self.pool) .await .map(|_| ()) @@ -1601,6 +1603,7 @@ impl BlockchainCacheDatabase { total_amount0_deposited::TEXT, total_amount1_deposited::TEXT, total_amount0_collected::TEXT, total_amount1_collected::TEXT, total_swaps, total_mints, total_burns, total_fee_collects, total_flashes, + liquidity_utilization_rate, (SELECT dex_name FROM pool WHERE chain_id = $1 AND address = $2) as dex_name FROM pool_snapshot WHERE chain_id = $1 AND pool_address = $2 AND is_valid = TRUE @@ -1649,6 +1652,7 @@ impl BlockchainCacheDatabase { total_burns: row.get::("total_burns") as u64, total_fee_collects: row.get::("total_fee_collects") as u64, total_flashes: row.get::("total_flashes") as u64, + liquidity_utilization_rate: row.get::("liquidity_utilization_rate"), }; // Load positions and ticks diff --git a/schema/sql/tables.sql b/schema/sql/tables.sql index 04860c06b8d9..dede7df4740c 100644 --- a/schema/sql/tables.sql +++ b/schema/sql/tables.sql @@ -457,6 +457,7 @@ CREATE TABLE IF NOT EXISTS "pool_snapshot" ( total_burns INTEGER NOT NULL DEFAULT 0, total_flashes INTEGER NOT NULL DEFAULT 0, total_fee_collects INTEGER NOT NULL, + liquidity_utilization_rate DOUBLE PRECISION DEFAULT 0, is_valid BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (chain_id, pool_address, block, transaction_index, log_index), From f507012e82cda825290a3c47d64aa8394182ddd0 Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Thu, 23 Oct 2025 11:01:51 +0200 Subject: [PATCH 3/6] Fix position fetching when hydrating pool snapshot from RPC --- crates/adapters/blockchain/src/data/core.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/adapters/blockchain/src/data/core.rs b/crates/adapters/blockchain/src/data/core.rs index 0f9dd486fd32..20a0c900b071 100644 --- a/crates/adapters/blockchain/src/data/core.rs +++ b/crates/adapters/blockchain/src/data/core.rs @@ -1562,7 +1562,7 @@ impl BlockchainDataClientCore { &profiler.pool.address, profiler.pool.instrument_id, profiler.get_active_tick_values().as_slice(), - &profiler.get_active_position_keys(), + &profiler.get_all_position_keys(), last_processed_event, ) .await?; From 7adec438128988adc59573f8e85503129baeb66b Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Thu, 23 Oct 2025 11:02:08 +0200 Subject: [PATCH 4/6] Add logging for liquidity utilization rate in pool profiler and actors --- crates/adapters/blockchain/bin/node_test.rs | 6 ++++++ crates/cli/src/blockchain/analyze.rs | 4 ++++ crates/model/src/python/defi/profiler.rs | 10 ++++++++++ python/examples/blockchain/actors.py | 5 +++++ 4 files changed, 25 insertions(+) diff --git a/crates/adapters/blockchain/bin/node_test.rs b/crates/adapters/blockchain/bin/node_test.rs index 2f15a8fb3ae8..7af1602fe251 100644 --- a/crates/adapters/blockchain/bin/node_test.rs +++ b/crates/adapters/blockchain/bin/node_test.rs @@ -263,10 +263,16 @@ impl DataActor for BlockchainSubscriberActor { let total_ticks = pool_profiler.get_active_tick_count(); let total_positions = pool_profiler.get_total_active_positions(); let liquidity = pool_profiler.get_active_liquidity(); + let liquidity_utilization_rate = pool_profiler.liquidity_utilization_rate(); log_info!( "Pool {pool_id} contains {total_ticks} active ticks and {total_positions} active positions with liquidity of {liquidity}", color = LogColor::Magenta ); + log_info!( + "Pool {pool_id} has a liquidity utilization rate of {:.4}%", + liquidity_utilization_rate * 100.0, + color = LogColor::Magenta + ); } else { log_warn!( "Pool profiler {} not found", diff --git a/crates/cli/src/blockchain/analyze.rs b/crates/cli/src/blockchain/analyze.rs index ebad146d13e4..53c495709f7d 100644 --- a/crates/cli/src/blockchain/analyze.rs +++ b/crates/cli/src/blockchain/analyze.rs @@ -130,5 +130,9 @@ pub async fn run_analyze_pool( data_client .check_snapshot_validity(&profiler, already_valid) .await?; + log::info!( + "Pool liquidity utilization rate is {:.4}%", + profiler.liquidity_utilization_rate() * 100.0 + ); Ok(()) } diff --git a/crates/model/src/python/defi/profiler.rs b/crates/model/src/python/defi/profiler.rs index e74b2a509c4f..d9f785283301 100644 --- a/crates/model/src/python/defi/profiler.rs +++ b/crates/model/src/python/defi/profiler.rs @@ -130,4 +130,14 @@ impl PoolProfiler { fn py_estimate_balance_of_token1(&self) -> String { self.estimate_balance_of_token1().to_string() } + + #[pyo3(name = "get_total_liquidity")] + fn py_get_total_liquidity_all_positions(&self) -> String { + self.get_total_liquidity().to_string() + } + + #[pyo3(name = "liquidity_utilization_rate")] + fn py_liquidity_utilization_rate(&self) -> f64 { + self.liquidity_utilization_rate() + } } diff --git a/python/examples/blockchain/actors.py b/python/examples/blockchain/actors.py index a0d48c9d922f..de8062555c31 100644 --- a/python/examples/blockchain/actors.py +++ b/python/examples/blockchain/actors.py @@ -123,10 +123,15 @@ def on_block(self, block: Block) -> None: total_ticks = pool.get_active_tick_count() total_positions = pool.get_total_active_positions() liquidity = pool.get_active_liquidity() + liquidity_utilization_rate = pool.liquidity_utilization_rate() self.log.info( f"Pool {pool_id} contains {total_ticks} active ticks and {total_positions} active positions with liquidity of {liquidity}", LogColor.BLUE, ) + self.log.info( + f"Pool {pool_id} has a liquidity utilization rate of {liquidity_utilization_rate * 100:.4f}%", + LogColor.BLUE, + ) def on_pool_swap(self, swap: PoolSwap) -> None: """ From b5dfce88b02b972afe420df63279ee950bb300e7 Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Thu, 23 Oct 2025 11:03:17 +0200 Subject: [PATCH 5/6] Replace `get_active_position_keys` with `get_all_position_keys` in pool profiler --- crates/model/src/defi/pool_analysis/profiler.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/model/src/defi/pool_analysis/profiler.rs b/crates/model/src/defi/pool_analysis/profiler.rs index 282fa9d98202..6628bfb82307 100644 --- a/crates/model/src/defi/pool_analysis/profiler.rs +++ b/crates/model/src/defi/pool_analysis/profiler.rs @@ -1442,14 +1442,6 @@ impl PoolProfiler { .collect() } - /// Returns position keys for all currently active positions. - pub fn get_active_position_keys(&self) -> Vec<(Address, i32, i32)> { - self.get_active_positions() - .iter() - .map(|position| (position.owner, position.tick_lower, position.tick_upper)) - .collect() - } - /// Returns a list of all positions tracked by the profiler. /// /// This includes both active and inactive positions, regardless of their @@ -1462,6 +1454,14 @@ impl PoolProfiler { self.positions.values().collect() } + /// Returns position keys for all tracked positions. + pub fn get_all_position_keys(&self) -> Vec<(Address, i32, i32)> { + self.get_all_positions() + .iter() + .map(|position| (position.owner, position.tick_lower, position.tick_upper)) + .collect() + } + /// Extracts a complete snapshot of the current pool state. /// /// Extracts and bundles the complete pool state including global variables, From 0140dd1ac8d6a27d16b0c244cfec5a7356ae5e7c Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Thu, 23 Oct 2025 11:08:07 +0200 Subject: [PATCH 6/6] Add calculation and tracking of liquidity utilization rate in pool profiler --- .../model/src/defi/pool_analysis/profiler.rs | 48 +++++++++++++++++++ .../model/src/defi/pool_analysis/snapshot.rs | 3 ++ crates/model/src/defi/pool_analysis/tests.rs | 26 ++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/model/src/defi/pool_analysis/profiler.rs b/crates/model/src/defi/pool_analysis/profiler.rs index 6628bfb82307..799976f19cf8 100644 --- a/crates/model/src/defi/pool_analysis/profiler.rs +++ b/crates/model/src/defi/pool_analysis/profiler.rs @@ -294,6 +294,7 @@ impl PoolProfiler { swap.log_index, )); self.update_reporter_if_enabled(swap.block); + self.update_liquidity_analytics(); Ok(()) } @@ -747,6 +748,7 @@ impl PoolProfiler { update.log_index, )); self.update_reporter_if_enabled(update.block); + self.update_liquidity_analytics(); Ok(()) } @@ -877,6 +879,7 @@ impl PoolProfiler { update.log_index, )); self.update_reporter_if_enabled(update.block); + self.update_liquidity_analytics(); Ok(()) } @@ -991,6 +994,7 @@ impl PoolProfiler { collect.log_index, )); self.update_reporter_if_enabled(collect.block); + self.update_liquidity_analytics(); Ok(()) } @@ -1022,6 +1026,7 @@ impl PoolProfiler { flash.log_index, )); self.update_reporter_if_enabled(flash.block); + self.update_liquidity_analytics(); Ok(()) } @@ -1235,6 +1240,32 @@ impl PoolProfiler { } } + /// Calculates the liquidity utilization rate for the pool. + /// + /// The utilization rate measures what percentage of total deployed liquidity + /// is currently active (in-range and earning fees) at the current price tick. + pub fn liquidity_utilization_rate(&self) -> f64 { + let total_liquidity = self.get_total_liquidity(); + let active_liquidity = self.get_active_liquidity(); + + if total_liquidity == U256::ZERO { + return 0.0; + } + + // 6 decimal places + const PRECISION: u32 = 1_000_000; + let ratio = FullMath::mul_div( + U256::from(active_liquidity), + U256::from(PRECISION), + total_liquidity, + ) + .unwrap_or(U256::ZERO); + + // Safe to cast to u64: Since active_liquidity <= total_liquidity, + // the ratio is guaranteed to be <= PRECISION (1_000_000), which fits in u64 + ratio.to::() as f64 / PRECISION as f64 + } + /// Validates tick range for position operations. /// /// Ensures ticks are properly ordered, aligned to tick spacing, and within @@ -1267,6 +1298,11 @@ impl PoolProfiler { Ok(()) } + /// Updates all liquidity analytics. + fn update_liquidity_analytics(&mut self) { + self.analytics.liquidity_utilization_rate = self.liquidity_utilization_rate(); + } + /// Returns the pool's active liquidity tracked by the tick map. /// /// This represents the effective liquidity available for trading at the current price. @@ -1298,6 +1334,15 @@ impl PoolProfiler { .sum() } + /// Calculates total liquidity across all positions, regardless of range status. + #[must_use] + pub fn get_total_liquidity(&self) -> U256 { + self.positions + .values() + .map(|position| U256::from(position.liquidity)) + .fold(U256::ZERO, |acc, liq| acc + liq) + } + /// Restores the profiler state from a saved snapshot. /// /// This method allows resuming profiling from a previously saved state, @@ -1358,6 +1403,9 @@ impl PoolProfiler { // Mark as initialized self.is_initialized = true; + // Recalculate analytics + self.update_liquidity_analytics(); + Ok(()) } diff --git a/crates/model/src/defi/pool_analysis/snapshot.rs b/crates/model/src/defi/pool_analysis/snapshot.rs index 079a6080b045..79833a2258ae 100644 --- a/crates/model/src/defi/pool_analysis/snapshot.rs +++ b/crates/model/src/defi/pool_analysis/snapshot.rs @@ -157,6 +157,8 @@ pub struct PoolAnalytics { pub total_fee_collects: u64, /// Total number of flash events processed. pub total_flashes: u64, + /// Liquidity utilization rate (active liquidity / total liquidity) + pub liquidity_utilization_rate: f64, } impl Default for PoolAnalytics { @@ -171,6 +173,7 @@ impl Default for PoolAnalytics { total_burns: 0, total_fee_collects: 0, total_flashes: 0, + liquidity_utilization_rate: 0.0, } } } diff --git a/crates/model/src/defi/pool_analysis/tests.rs b/crates/model/src/defi/pool_analysis/tests.rs index db0aef94e112..445cdcb762c3 100644 --- a/crates/model/src/defi/pool_analysis/tests.rs +++ b/crates/model/src/defi/pool_analysis/tests.rs @@ -744,6 +744,10 @@ fn test_uni_pool_profiler_initial_state(uni_pool_profiler: PoolProfiler) { ); assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 0); + + // Liquidity utilization should be 100% since all liquidity is in-range + assert_eq!(uni_pool_profiler.get_total_liquidity(), 3161); + assert_eq!(uni_pool_profiler.liquidity_utilization_rate(), 1.0); } // ---------- TEST MINTS ABOVE CURRENT PRICE ---------- @@ -907,7 +911,9 @@ fn test_if_removing_of_liquidity_works_after_mint(mut uni_pool_profiler: PoolPro // After collect, position should be cleaned up since it's completely empty assert!( - uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick).is_none(), + uni_pool_profiler + .get_position(&lp_address(), lower_tick, upper_tick) + .is_none(), "Position should be cleaned up after collecting all fees" ); @@ -1343,7 +1349,9 @@ fn test_mint_then_burning_and_collecting(mut uni_pool_profiler: PoolProfiler) { // After collect, position should be cleaned up since it's completely empty assert!( - uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick).is_none(), + uni_pool_profiler + .get_position(&lp_address(), lower_tick, upper_tick) + .is_none(), "Position should be cleaned up after collecting all fees" ); @@ -1447,7 +1455,9 @@ fn test_if_mint_below_current_price_works_after_burn_and_fee_collect( // After collect, position should be cleaned up since it's completely empty assert!( - uni_pool_profiler.get_position(&lp_address(), lower_tick, upper_tick).is_none(), + uni_pool_profiler + .get_position(&lp_address(), lower_tick, upper_tick) + .is_none(), "Position should be cleaned up after collecting all fees" ); @@ -1848,6 +1858,11 @@ fn test_swap_crossing_tick_down_activates_position(mut uni_pool_profiler: PoolPr assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 1); + // Liquidity utilization before swap: active (3161) / total (3161 + 50000) = ~5.95% + assert_eq!(uni_pool_profiler.get_total_liquidity(), U256::from(53161)); + let utilization_before = 3161.0 / 53161.0; + assert!((uni_pool_profiler.liquidity_utilization_rate() - utilization_before).abs() < 1e-6); + // Execute swap: token0 for token1 to move price down into the position range let amount0_in = U256::from(expand_to_18_decimals(1)); let _ = uni_pool_profiler @@ -1915,6 +1930,11 @@ fn test_swap_crossing_tick_up_activates_position(mut uni_pool_profiler: PoolProf assert_eq!(uni_pool_profiler.get_total_active_positions(), 1); assert_eq!(uni_pool_profiler.get_total_inactive_positions(), 1); + // Liquidity utilization before swap: active (3161) / total (3161 + 40000) = ~7.32% + assert_eq!(uni_pool_profiler.get_total_liquidity(), U256::from(43161)); + let utilization_before = 3161.0 / 43161.0; + assert!((uni_pool_profiler.liquidity_utilization_rate() - utilization_before).abs() < 1e-6); + // Execute large swap: token1 for token0 to move price up, crossing tick -22980 let amount1_in = U256::from(expand_to_18_decimals(1000)); let _ = uni_pool_profiler