Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/adapters/blockchain/bin/node_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions crates/adapters/blockchain/src/cache/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(|_| ())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1649,6 +1652,7 @@ impl BlockchainCacheDatabase {
total_burns: row.get::<i32, _>("total_burns") as u64,
total_fee_collects: row.get::<i32, _>("total_fee_collects") as u64,
total_flashes: row.get::<i32, _>("total_flashes") as u64,
liquidity_utilization_rate: row.get::<f64, _>("liquidity_utilization_rate"),
};

// Load positions and ticks
Expand Down
2 changes: 1 addition & 1 deletion crates/adapters/blockchain/src/data/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/blockchain/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
84 changes: 76 additions & 8 deletions crates/model/src/defi/pool_analysis/profiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ impl PoolProfiler {
swap.log_index,
));
self.update_reporter_if_enabled(swap.block);
self.update_liquidity_analytics();

Ok(())
}
Expand Down Expand Up @@ -747,6 +748,7 @@ impl PoolProfiler {
update.log_index,
));
self.update_reporter_if_enabled(update.block);
self.update_liquidity_analytics();

Ok(())
}
Expand Down Expand Up @@ -877,6 +879,7 @@ impl PoolProfiler {
update.log_index,
));
self.update_reporter_if_enabled(update.block);
self.update_liquidity_analytics();

Ok(())
}
Expand Down Expand Up @@ -977,6 +980,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);

Expand All @@ -988,6 +994,7 @@ impl PoolProfiler {
collect.log_index,
));
self.update_reporter_if_enabled(collect.block);
self.update_liquidity_analytics();

Ok(())
}
Expand Down Expand Up @@ -1019,6 +1026,7 @@ impl PoolProfiler {
flash.log_index,
));
self.update_reporter_if_enabled(flash.block);
self.update_liquidity_analytics();

Ok(())
}
Expand Down Expand Up @@ -1215,6 +1223,49 @@ 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);
}
}

/// 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::<u64>() as f64 / PRECISION as f64
}

/// Validates tick range for position operations.
///
/// Ensures ticks are properly ordered, aligned to tick spacing, and within
Expand Down Expand Up @@ -1247,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.
Expand Down Expand Up @@ -1278,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,
Expand Down Expand Up @@ -1338,6 +1403,9 @@ impl PoolProfiler {
// Mark as initialized
self.is_initialized = true;

// Recalculate analytics
self.update_liquidity_analytics();

Ok(())
}

Expand Down Expand Up @@ -1422,14 +1490,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
Expand All @@ -1442,6 +1502,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,
Expand Down
3 changes: 3 additions & 0 deletions crates/model/src/defi/pool_analysis/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -171,6 +173,7 @@ impl Default for PoolAnalytics {
total_burns: 0,
total_fee_collects: 0,
total_flashes: 0,
liquidity_utilization_rate: 0.0,
}
}
}
85 changes: 46 additions & 39 deletions crates/model/src/defi/pool_analysis/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Expand Down Expand Up @@ -905,18 +909,17 @@ 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]
Expand Down Expand Up @@ -1344,22 +1347,17 @@ 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 ----------
Expand Down Expand Up @@ -1455,18 +1453,17 @@ 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]
Expand Down Expand Up @@ -1861,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
Expand Down Expand Up @@ -1928,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
Expand Down
Loading
Loading