Skip to content
216 changes: 210 additions & 6 deletions fynd-core/src/algorithm/most_liquid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pub struct MostLiquidAlgorithm {
pub struct DepthAndPrice {
/// Spot price (token_out per token_in) for this edge direction.
pub spot_price: f64,
/// Liquidity depth in raw units of the sell token.
/// Liquidity depth normalized to gas token (native token) units.
pub depth: f64,
}

Expand Down Expand Up @@ -108,7 +108,7 @@ impl crate::graph::EdgeWeightFromSimAndDerived for DepthAndPrice {
};

// Look up pre-computed depth; skip edge if unavailable.
let depth = match derived
let raw_depth = match derived
.pool_depths()
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note that I chose to not change how pool_depth was calculated. It's still denominated in sell_token, we just normalize here for this algorithm usage.

Fixing the calculation would change a lot, as it would create another dependency layer (pool_depth needs token_prices). IMO this is fine to be done here - we can move to the derived_data layer if preferred.

.and_then(|d| d.get(&key))
{
Expand All @@ -119,6 +119,65 @@ impl crate::graph::EdgeWeightFromSimAndDerived for DepthAndPrice {
}
};

// Normalize depth from raw token_in units to gas token units.
// TokenGasPrices stores Price { numerator, denominator } where
// numerator/denominator = "token units per gas token unit".
// To convert to gas token: depth_gas = raw_depth * denominator / numerator.
let depth = match derived
.token_prices()
.and_then(|p| p.get(&token_in.address))
{
Some(price) => {
let num = match price.numerator.to_f64() {
Some(v) if v > 0.0 => v,
Some(_) => {
trace!(
component_id = %component_id,
token_in = %token_in.address,
"token price numerator is zero, skipping edge"
);
return None;
}
None => {
trace!(
component_id = %component_id,
token_in = %token_in.address,
"token price numerator overflows f64, skipping edge"
);
return None;
}
};
let den = match price.denominator.to_f64() {
Some(v) if v > 0.0 => v,
Some(_) => {
trace!(
component_id = %component_id,
token_in = %token_in.address,
"token price denominator is zero, skipping edge"
);
return None;
}
None => {
trace!(
component_id = %component_id,
token_in = %token_in.address,
"token price denominator overflows f64, skipping edge"
);
return None;
}
};
raw_depth * den / num
}
None => {
trace!(
component_id = %component_id,
token_in = %token_in.address,
"token price not found, skipping edge"
);
return None;
}
};

Some(Self { spot_price, depth })
}
}
Expand Down Expand Up @@ -617,12 +676,13 @@ impl Algorithm for MostLiquidAlgorithm {
}

fn computation_requirements(&self) -> ComputationRequirements {
// MostLiquidAlgorithm uses token prices to convert gas costs from wei
// to output token terms for accurate amount_out_net_gas calculation.
// MostLiquidAlgorithm uses token prices for two purposes:
// 1. Converting gas costs from wei to output token terms (net_amount_out)
// 2. Normalizing pool depth to gas token units for path scoring (from_sim_and_derived)
//
// Token prices are marked as `allow_stale` since they don't change much
// block-to-block and having slightly stale prices is acceptable for
// gas cost estimation.
// block-to-block. Stale prices affect scoring order (not correctness)
// and gas cost estimation accuracy.
ComputationRequirements::none()
.allow_stale("token_prices")
.expect("Conflicting Computation Requirements")
Expand Down Expand Up @@ -765,6 +825,18 @@ mod tests {
format!("{comp}/{}/{}", addr(b_in), addr(b_out))
}

fn make_token_prices(addresses: &[Address]) -> TokenGasPrices {
let mut prices = TokenGasPrices::new();
for addr in addresses {
// 1:1 price (1 token unit = 1 gas token unit)
prices.insert(
addr.clone(),
Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
);
}
prices
}

#[test]
fn test_from_sim_and_derived_failed_spot_price_returns_none() {
let key = pair_key("pool1", 0x01, 0x02);
Expand All @@ -784,6 +856,12 @@ mod tests {
true,
);
derived.set_pool_depths(Default::default(), vec![], 10, true);
derived.set_token_prices(
make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
vec![],
10,
true,
);

let sim = make_mock_sim();
let result =
Expand Down Expand Up @@ -816,6 +894,12 @@ mod tests {
10,
true,
);
derived.set_token_prices(
make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
vec![],
10,
true,
);

let sim = make_mock_sim();
let result =
Expand Down Expand Up @@ -852,6 +936,12 @@ mod tests {
10,
true,
);
derived.set_token_prices(
make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
vec![],
10,
true,
);

let sim = make_mock_sim();
let result =
Expand All @@ -862,6 +952,120 @@ mod tests {
assert!(result.is_none());
}

#[test]
fn test_from_sim_and_derived_missing_token_price_returns_none() {
let key = pair_key("pool1", 0x01, 0x02);
let tok_in = token(0x01, "A");
let tok_out = token(0x02, "B");

let mut derived = DerivedData::new();
// Spot price and pool depth both present
let mut prices = crate::derived::types::SpotPrices::default();
prices.insert(key.clone(), 1.5);
derived.set_spot_prices(prices, vec![], 10, true);

let mut depths = crate::derived::types::PoolDepths::default();
depths.insert(key.clone(), BigUint::from(1000u64));
derived.set_pool_depths(depths, vec![], 10, true);

// No token prices set — normalization should return None

let sim = make_mock_sim();
let result =
<DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
&sim, &key.0, &tok_in, &tok_out, &derived,
);

assert!(
result.is_none(),
"should return None when token price is missing for depth normalization"
);
}

#[test]
fn test_from_sim_and_derived_normalizes_depth_to_eth() {
let key = pair_key("pool1", 0x01, 0x02);
let tok_in = token(0x01, "A");
let tok_out = token(0x02, "B");

let mut derived = DerivedData::new();

// Spot price
let mut spot = crate::derived::types::SpotPrices::default();
spot.insert(key.clone(), 2.0);
derived.set_spot_prices(spot, vec![], 10, true);

// Raw depth: 2_000_000 token_in units
let mut depths = crate::derived::types::PoolDepths::default();
depths.insert(key.clone(), BigUint::from(2_000_000u64));
derived.set_pool_depths(depths, vec![], 10, true);

// Token price: 2000 token_in per 1 ETH (numerator=2000, denominator=1)
// So 2_000_000 raw units / 2000 = 1000 ETH
let mut token_prices = TokenGasPrices::new();
token_prices.insert(
tok_in.address.clone(),
Price { numerator: BigUint::from(2000u64), denominator: BigUint::from(1u64) },
);
derived.set_token_prices(token_prices, vec![], 10, true);

let sim = make_mock_sim();
let result =
<DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
&sim, &key.0, &tok_in, &tok_out, &derived,
);

let data = result.expect("should return Some when all data present");
assert!((data.spot_price - 2.0).abs() < f64::EPSILON, "spot price should be 2.0");
// depth_in_eth = 2_000_000 * 1 / 2000 = 1000.0
assert!(
(data.depth - 1000.0).abs() < f64::EPSILON,
"depth should be 1000.0 ETH, got {}",
data.depth
);
}

#[test]
fn test_from_sim_and_derived_normalizes_depth_fractional_price() {
let key = pair_key("pool1", 0x01, 0x02);
let tok_in = token(0x01, "A");
let tok_out = token(0x02, "B");

let mut derived = DerivedData::new();

let mut spot = crate::derived::types::SpotPrices::default();
spot.insert(key.clone(), 0.5);
derived.set_spot_prices(spot, vec![], 10, true);

// Raw depth: 500 token_in units
let mut depths = crate::derived::types::PoolDepths::default();
depths.insert(key.clone(), BigUint::from(500u64));
derived.set_pool_depths(depths, vec![], 10, true);

// Token price: numerator=3, denominator=2 -> 1.5 tokens per ETH
// depth_in_eth = 500 * 2 / 3 = 333.333...
let mut token_prices = TokenGasPrices::new();
token_prices.insert(
tok_in.address.clone(),
Price { numerator: BigUint::from(3u64), denominator: BigUint::from(2u64) },
);
derived.set_token_prices(token_prices, vec![], 10, true);

let sim = make_mock_sim();
let result =
<DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
&sim, &key.0, &tok_in, &tok_out, &derived,
);

let data = result.expect("should return Some when all data present");
let expected_depth = 500.0 * 2.0 / 3.0;
assert!(
(data.depth - expected_depth).abs() < 1e-10,
"depth should be {expected_depth}, got {}",
data.depth
);
}

// ==================== find_paths Tests ====================

fn all_ids(paths: Vec<Path<'_, DepthAndPrice>>) -> HashSet<Vec<&str>> {
Expand Down
Loading