use std::time::{Duration, Instant};
use num_bigint::{BigInt, BigUint};
use num_traits::{ToPrimitive, Zero};
use tracing::debug;
use super::{
bellman_ford::{BellmanFordContext, FindRouteOptions},
split_primitives::{
build_post_swap_overrides, build_split_route, compute_marginal_price_product,
evaluate_total_output, golden_section_search, normalize_fractions, simulate_path,
split_amount, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
},
Algorithm, AlgorithmConfig, AlgorithmError, BellmanFordAlgorithm,
};
use crate::{
derived::{computation::ComputationRequirements, SharedDerivedDataRef},
feed::market_data::{MarketData, StateLabel},
graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
types::{quote::Order, OrderSide, Route, RouteResult},
};
#[derive(Debug, Clone)]
pub struct PathFrankWolfeConfig {
pub max_paths: usize,
pub max_probe: f64,
pub min_split: f64,
pub line_search_evals: usize,
}
impl Default for PathFrankWolfeConfig {
fn default() -> Self {
Self { max_paths: 4, max_probe: 0.25, min_split: 0.05, line_search_evals: 12 }
}
}
pub struct PathFrankWolfeAlgorithm {
inner: BellmanFordAlgorithm,
config: PathFrankWolfeConfig,
}
impl PathFrankWolfeAlgorithm {
pub(crate) fn new(algorithm_config: AlgorithmConfig, config: PathFrankWolfeConfig) -> Self {
let inner = BellmanFordAlgorithm::with_config(algorithm_config);
Self { inner, config }
}
}
impl Default for PathFrankWolfeAlgorithm {
fn default() -> Self {
Self::new(AlgorithmConfig::default(), PathFrankWolfeConfig::default())
}
}
impl PathFrankWolfeAlgorithm {
fn compute_probe_amount(
&self,
total_amount: &BigUint,
price_impact: f64,
gas_cost_output_tokens: f64,
) -> Option<BigUint> {
if price_impact <= 0.0 {
return None;
}
let gas_floor = gas_cost_output_tokens / price_impact;
let probe_amount = BigUint::from(gas_floor.ceil() as u128);
let (max_probe_amount, _remainder) = split_amount(total_amount, self.config.max_probe);
if probe_amount > max_probe_amount {
return None;
}
Some(probe_amount)
}
fn compute_average_price_impact(paths: &[PathAllocation]) -> Result<f64, AlgorithmError> {
let mut weighted_price_impact = 0.0;
for path in paths {
let amount_in = path.amount_in.to_f64().ok_or_else(|| {
AlgorithmError::Other(format!("amount_in too large for f64: {}", path.amount_in))
})?;
let amount_out = path
.amount_out
.to_f64()
.ok_or_else(|| {
AlgorithmError::Other(format!(
"amount_out too large for f64: {}",
path.amount_out
))
})?;
if amount_in <= 0.0 {
return Err(AlgorithmError::Other(format!("non-positive amount_in ({amount_in})")));
}
if path.marginal_price_product <= 0.0 {
return Err(AlgorithmError::Other(format!(
"non-positive marginal_price_product ({})",
path.marginal_price_product
)));
}
let ideal_out = amount_in * path.marginal_price_product;
let price_impact = 1.0 - amount_out / ideal_out;
weighted_price_impact += path.flow_fraction * price_impact;
}
Ok(weighted_price_impact)
}
pub(crate) fn find_candidate_path(
&self,
ctx: &BellmanFordContext,
current_allocations: &[PathAllocation],
probe_amount: &BigUint,
) -> Result<Vec<SimulatedHop>, AlgorithmError> {
let mut overrides = build_post_swap_overrides(current_allocations, &ctx.market_data);
for alloc in current_allocations {
for hop in &alloc.hops {
overrides = overrides.with_zero_gas(
hop.descriptor.component_id.clone(),
hop.descriptor.token_in.address.clone(),
hop.descriptor.token_out.address.clone(),
);
}
}
let token_in = ctx
.node_address
.get(&ctx.token_in_node)
.cloned()
.ok_or_else(|| AlgorithmError::DataNotFound {
kind: "token_in node index",
id: Some(format!("{:?}", ctx.token_in_node)),
})?;
let token_out = ctx
.node_address
.get(&ctx.token_out_node)
.cloned()
.ok_or_else(|| AlgorithmError::DataNotFound {
kind: "token_out node index",
id: Some(format!("{:?}", ctx.token_out_node)),
})?;
let probe_order = Order::new(
token_in,
token_out,
probe_amount.clone(),
OrderSide::Sell,
Default::default(),
);
let result =
self.inner
.find_single_route(ctx, &probe_order, FindRouteOptions { overrides })?;
let route = result.route();
let tokens = route.tokens();
route
.swaps()
.iter()
.map(|swap| {
let token_in = tokens
.get(swap.token_in())
.cloned()
.ok_or_else(|| AlgorithmError::DataNotFound {
kind: "token",
id: Some(format!("{:?}", swap.token_in())),
})?;
let token_out = tokens
.get(swap.token_out())
.cloned()
.ok_or_else(|| AlgorithmError::DataNotFound {
kind: "token",
id: Some(format!("{:?}", swap.token_out())),
})?;
Ok(SimulatedHop {
descriptor: HopDescriptor::new(
swap.component_id().to_string(),
token_in,
token_out,
),
amount_out: swap.amount_out().clone(),
gas: swap.gas_estimate().clone(),
})
})
.collect()
}
fn gas_cost_output_tokens(
route: &Route,
ctx: &BellmanFordContext,
) -> Result<f64, AlgorithmError> {
let gas_price = match &ctx.gas_price_wei {
Some(gp) if !gp.is_zero() => gp,
_ => return Ok(0.0),
};
let last_swap = route
.swaps()
.last()
.ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
let price = match ctx
.token_prices
.as_ref()
.and_then(|tp| tp.get(last_swap.token_out()))
{
Some(p) if !p.denominator.is_zero() => p,
_ => return Ok(0.0),
};
let gas_cost_wei = &route.total_gas() * gas_price;
let gas_cost_tokens = &gas_cost_wei * &price.numerator / &price.denominator;
Ok(gas_cost_tokens.to_f64().unwrap_or(0.0))
}
fn route_to_allocation(
route: &Route,
order: &Order,
ctx: &BellmanFordContext,
) -> Result<PathAllocation, AlgorithmError> {
let tokens = route.tokens();
let hops: Vec<SimulatedHop> = route
.swaps()
.iter()
.map(|swap| {
let token_in = tokens
.get(swap.token_in())
.cloned()
.ok_or_else(|| AlgorithmError::DataNotFound {
kind: "token",
id: Some(format!("{:?}", swap.token_in())),
})?;
let token_out = tokens
.get(swap.token_out())
.cloned()
.ok_or_else(|| AlgorithmError::DataNotFound {
kind: "token",
id: Some(format!("{:?}", swap.token_out())),
})?;
Ok(SimulatedHop {
descriptor: HopDescriptor::new(
swap.component_id().to_string(),
token_in,
token_out,
),
amount_out: swap.amount_out().clone(),
gas: swap.gas_estimate().clone(),
})
})
.collect::<Result<_, AlgorithmError>>()?;
if hops.is_empty() {
return Err(AlgorithmError::DataNotFound {
kind: "swap",
id: Some("route contains no swaps".to_string()),
});
}
let descriptors: Vec<HopDescriptor> = hops
.iter()
.map(|h| h.descriptor.clone())
.collect();
let overrides = MarketOverrides::empty();
let marginal_price_product =
compute_marginal_price_product(&descriptors, &ctx.market_data, &overrides)?;
let amount_out = hops
.last()
.map(|h| h.amount_out.clone())
.unwrap_or_default();
Ok(PathAllocation {
hops,
flow_fraction: 1.0,
amount_in: order.amount().clone(),
amount_out,
marginal_price_product,
})
}
fn optimize_step_size(
&self,
current_allocations: &[PathAllocation],
candidate: &[SimulatedHop],
total_amount: &BigUint,
ctx: &BellmanFordContext,
) -> f64 {
let existing_descriptors: Vec<Vec<HopDescriptor>> = current_allocations
.iter()
.map(|a| {
a.hops
.iter()
.map(|h| h.descriptor.clone())
.collect()
})
.collect();
let candidate_descriptors: Vec<HopDescriptor> = candidate
.iter()
.map(|h| h.descriptor.clone())
.collect();
let overrides = MarketOverrides::empty();
let evaluate_split = |step_size: f64| -> f64 {
let mut trial_fractions: Vec<f64> = current_allocations
.iter()
.map(|a| a.flow_fraction * (1.0 - step_size))
.collect();
trial_fractions.push(step_size);
let mut trial_paths: Vec<&[HopDescriptor]> = existing_descriptors
.iter()
.map(|v| v.as_slice())
.collect();
trial_paths.push(&candidate_descriptors);
match evaluate_total_output(
&trial_paths,
&trial_fractions,
total_amount,
&ctx.market_data,
&overrides,
) {
Ok((total_output, _gas)) => total_output.to_f64().unwrap_or(0.0),
Err(_) => 0.0,
}
};
golden_section_search(evaluate_split, 0.0, 1.0, self.config.line_search_evals)
}
fn apply_step(
&self,
allocations: &mut Vec<PathAllocation>,
candidate: &[SimulatedHop],
step_size: f64,
total_amount: &BigUint,
ctx: &BellmanFordContext,
) -> Result<(), AlgorithmError> {
for alloc in allocations.iter_mut() {
alloc.flow_fraction *= 1.0 - step_size;
}
allocations.push(PathAllocation {
hops: candidate.to_vec(),
flow_fraction: step_size,
amount_in: BigUint::zero(),
amount_out: BigUint::zero(),
marginal_price_product: 0.0,
});
allocations.retain(|a| a.flow_fraction >= self.config.min_split);
let mut remaining_fractions: Vec<f64> = allocations
.iter()
.map(|a| a.flow_fraction)
.collect();
normalize_fractions(&mut remaining_fractions)
.map_err(|e| AlgorithmError::Other(e.to_string()))?;
let overrides = MarketOverrides::empty();
for (alloc, &frac) in allocations
.iter_mut()
.zip(remaining_fractions.iter())
{
alloc.flow_fraction = frac;
let (alloc_amount_in, _) = split_amount(total_amount, frac);
let hop_descriptors: Vec<HopDescriptor> = alloc
.hops
.iter()
.map(|h| h.descriptor.clone())
.collect();
let sim =
simulate_path(&hop_descriptors, &alloc_amount_in, &ctx.market_data, &overrides)?;
alloc.amount_in = alloc_amount_in;
alloc.amount_out = sim.amount_out;
alloc.marginal_price_product = sim.marginal_price_product;
}
Ok(())
}
fn compute_split_net_amount_out(
route: &Route,
ctx: &BellmanFordContext,
) -> Result<BigInt, AlgorithmError> {
let last_swap = route
.swaps()
.last()
.ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
let output_token = last_swap.token_out();
let total_out: BigUint = route
.swaps()
.iter()
.filter(|s| s.token_out() == output_token)
.map(|s| s.amount_out().clone())
.fold(BigUint::zero(), |acc, x| acc + x);
let gas_cost = Self::gas_cost_output_tokens(route, ctx)?;
let gas_cost_tokens = BigUint::from(gas_cost.ceil() as u128);
Ok(BigInt::from(total_out) - BigInt::from(gas_cost_tokens))
}
pub(crate) fn is_duplicate_path(
candidate: &[SimulatedHop],
existing: &[PathAllocation],
) -> bool {
existing.iter().any(|alloc| {
alloc.hops.len() == candidate.len() &&
alloc
.hops
.iter()
.zip(candidate.iter())
.all(|(a, b)| {
a.descriptor.component_id == b.descriptor.component_id &&
a.descriptor.token_in.address == b.descriptor.token_in.address &&
a.descriptor.token_out.address == b.descriptor.token_out.address
})
})
}
}
impl Algorithm for PathFrankWolfeAlgorithm {
type GraphType = StableDiGraph<()>;
type GraphManager = PetgraphStableDiGraphManager<()>;
fn name(&self) -> &str {
"path_frank_wolfe"
}
async fn find_best_route(
&self,
graph: &Self::GraphType,
market: MarketData,
label: Option<StateLabel>,
derived: Option<SharedDerivedDataRef>,
order: &Order,
) -> Result<RouteResult, AlgorithmError> {
let start = Instant::now();
let ctx = self
.inner
.build_context(graph, market, label, derived, order)
.await?;
let single_path_result =
self.inner
.find_single_route(&ctx, order, FindRouteOptions::default())?;
let mut allocations =
vec![Self::route_to_allocation(single_path_result.route(), order, &ctx)?];
let gas_cost = Self::gas_cost_output_tokens(single_path_result.route(), &ctx)?;
let total_amount = order.amount();
let initial_pi = Self::compute_average_price_impact(&allocations)?;
if self
.compute_probe_amount(total_amount, initial_pi, gas_cost)
.is_none()
{
debug!(pi = initial_pi, gas_cost, "price impact too low to justify splitting");
return Ok(single_path_result);
}
for iteration in 1..self.config.max_paths {
if start.elapsed() >= self.timeout() {
debug!(iteration, "pfw timeout, returning partial result");
break;
}
let pi = Self::compute_average_price_impact(&allocations)?;
let probe_amount = match self.compute_probe_amount(total_amount, pi, gas_cost) {
Some(p) => p,
None => {
debug!(iteration, pi, "probe exceeds cap, stopping");
break;
}
};
let candidate = match self.find_candidate_path(&ctx, &allocations, &probe_amount) {
Ok(c) => c,
Err(e) => {
debug!(
iteration,
?e,
"no additional candidate path found, stopping further searches"
);
break;
}
};
if Self::is_duplicate_path(&candidate, &allocations) {
debug!(iteration, "duplicate path, exploration exhausted");
break;
}
let step_size = self.optimize_step_size(&allocations, &candidate, total_amount, &ctx);
if step_size < self.config.min_split {
debug!(iteration, step_size, "step size below min_split, stopping");
break;
}
self.apply_step(&mut allocations, &candidate, step_size, total_amount, &ctx)?;
debug!(iteration, paths = allocations.len(), step_size, "pfw iteration complete");
}
if allocations.len() <= 1 {
return Ok(single_path_result);
}
let split_route = build_split_route(&allocations, &ctx.market_data, order)?;
let gas_price = ctx
.gas_price_wei
.clone()
.unwrap_or_default();
let split_net = Self::compute_split_net_amount_out(&split_route, &ctx)?;
let split_result = RouteResult::new(split_route, split_net, gas_price);
if split_result.net_amount_out() > single_path_result.net_amount_out() {
debug!(
split_net = %split_result.net_amount_out(),
initial_net = %single_path_result.net_amount_out(),
paths = allocations.len(),
"split route beats single path"
);
Ok(split_result)
} else {
debug!(
split_net = %split_result.net_amount_out(),
initial_net = %single_path_result.net_amount_out(),
"single path still best"
);
Ok(single_path_result)
}
}
fn computation_requirements(&self) -> ComputationRequirements {
ComputationRequirements::none()
.allow_stale("token_prices")
.expect("token_prices requirement conflicts (bug)")
.allow_stale("spot_prices")
.expect("spot_prices requirement conflicts (bug)")
}
fn timeout(&self) -> Duration {
self.inner.timeout()
}
}
#[cfg(test)]
mod tests {
use std::{sync::Arc, time::Duration as StdDuration};
use tokio::sync::RwLock;
use tycho_simulation::tycho_common::{
models::token::Token,
simulation::protocol_sim::{Price, ProtocolSim},
};
use super::*;
use crate::{
algorithm::{
split_primitives::{build_split_route, MarketOverrides},
test_utils::{
order, setup_market_unweighted, token, ConstantProductSim, MockProtocolSim,
},
AlgorithmConfig,
},
derived::{types::TokenGasPrices, DerivedData, SharedDerivedDataRef},
graph::GraphManager,
types::OrderSide,
};
fn derived_with_token_prices(tokens: &[&Token]) -> SharedDerivedDataRef {
let mut prices = TokenGasPrices::new();
let price = Price::new(BigUint::from(1u64), BigUint::from(1_000_000u64));
for token in tokens {
prices.insert(token.address.clone(), price.clone());
}
let mut derived = DerivedData::new();
derived.set_token_prices(prices, vec![], 1, true);
Arc::new(RwLock::new(derived))
}
impl PathFrankWolfeAlgorithm {
fn pfw_config(&self) -> &PathFrankWolfeConfig {
&self.config
}
}
#[test]
fn test_with_pfw_config_override() {
let pfw_config = PathFrankWolfeConfig {
max_paths: 8,
max_probe: 0.5,
min_split: 0.1,
line_search_evals: 24,
};
let algo = PathFrankWolfeAlgorithm::new(AlgorithmConfig::default(), pfw_config);
assert_eq!(algo.pfw_config().max_paths, 8);
assert!((algo.pfw_config().max_probe - 0.5).abs() < f64::EPSILON);
assert!((algo.pfw_config().min_split - 0.1).abs() < f64::EPSILON);
assert_eq!(algo.pfw_config().line_search_evals, 24);
}
#[test]
fn test_probe_amount_low_impact() {
let total = BigUint::from(1_000_000u64);
let algo = PathFrankWolfeAlgorithm::default();
let result = algo.compute_probe_amount(&total, 0.001, 100_000.0);
assert!(result.is_none());
}
#[test]
fn test_probe_amount_scaling() {
let total = BigUint::from(10_000_000u64);
let algo = PathFrankWolfeAlgorithm::default();
let gas_cost = 1000.0;
let probe_high_pi = algo
.compute_probe_amount(&total, 0.10, gas_cost)
.unwrap();
let probe_low_pi = algo
.compute_probe_amount(&total, 0.05, gas_cost)
.unwrap();
assert!(probe_high_pi < probe_low_pi);
let ratio = probe_high_pi.to_f64().unwrap() / probe_low_pi.to_f64().unwrap();
assert!(
(ratio - 0.5).abs() < 0.01,
"expected ratio ~0.5 (inverse proportionality), got {ratio}"
);
}
#[test]
fn test_probe_amount_within_cap() {
let total = BigUint::from(1_000_000u64);
let algo = PathFrankWolfeAlgorithm::default();
let probe_amount = algo
.compute_probe_amount(&total, 0.10, 1000.0)
.unwrap();
assert_eq!(probe_amount, BigUint::from(10_000u64));
}
#[test]
fn test_probe_amount_zero_price_impact() {
let total = BigUint::from(1_000_000u64);
let algo = PathFrankWolfeAlgorithm::default();
assert!(algo
.compute_probe_amount(&total, 0.0, 1000.0)
.is_none());
}
#[test]
fn test_average_price_impact_redistribution() {
let iter_0 = [PathAllocation {
hops: vec![],
flow_fraction: 1.0,
amount_in: BigUint::from(100_000u64),
amount_out: BigUint::from(181_818u64),
marginal_price_product: 2.0,
}];
let iter_1 = [
PathAllocation {
hops: vec![],
flow_fraction: 0.5,
amount_in: BigUint::from(50_000u64),
amount_out: BigUint::from(95_238u64),
marginal_price_product: 2.0,
},
PathAllocation {
hops: vec![],
flow_fraction: 0.5,
amount_in: BigUint::from(50_000u64),
amount_out: BigUint::from(95_238u64),
marginal_price_product: 2.0,
},
];
let third = 1.0 / 3.0;
let iter_2 = [
PathAllocation {
hops: vec![],
flow_fraction: third,
amount_in: BigUint::from(33_333u64),
amount_out: BigUint::from(64_514u64),
marginal_price_product: 2.0,
},
PathAllocation {
hops: vec![],
flow_fraction: third,
amount_in: BigUint::from(33_333u64),
amount_out: BigUint::from(64_514u64),
marginal_price_product: 2.0,
},
PathAllocation {
hops: vec![],
flow_fraction: third,
amount_in: BigUint::from(33_334u64),
amount_out: BigUint::from(64_516u64),
marginal_price_product: 2.0,
},
];
let pi_0 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_0).unwrap();
let pi_1 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_1).unwrap();
let pi_2 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_2).unwrap();
assert!(pi_1 < pi_0, "price impact should decrease after first split: {pi_1} >= {pi_0}");
assert!(pi_2 < pi_1, "price impact should decrease after second split: {pi_2} >= {pi_1}");
assert!((pi_0 - 0.09091).abs() < 1e-5, "expected ~0.0909, got {pi_0}");
assert!((pi_1 - 0.04762).abs() < 1e-5, "expected ~0.0476, got {pi_1}");
assert!((pi_2 - 0.03228).abs() < 1e-5, "expected ~0.0323, got {pi_2}");
}
#[test]
fn test_average_price_impact_weighting() {
let allocations = [
PathAllocation {
hops: vec![],
flow_fraction: 0.9,
amount_in: BigUint::from(1000u64),
amount_out: BigUint::from(900u64),
marginal_price_product: 1.0,
},
PathAllocation {
hops: vec![],
flow_fraction: 0.1,
amount_in: BigUint::from(100u64),
amount_out: BigUint::from(50u64),
marginal_price_product: 1.0,
},
];
let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
assert!((pi - 0.14).abs() < 1e-10, "expected 0.14, got {pi}");
}
#[tokio::test]
async fn test_pi_exit_criterion_with_high_gas() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let cp = |gas: u64| -> Box<dyn ProtocolSim> {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(5_000u64),
reserve_1: BigUint::from(5_000u64),
gas,
})
};
let (market_hi, gm_hi) = setup_market_unweighted(vec![
("P1", &token_a, &token_b, cp(1_000_000)),
("P2", &token_a, &token_b, cp(1_000_000)),
("P3", &token_a, &token_b, cp(1_000_000)),
]);
let config = PathFrankWolfeConfig {
max_paths: 4,
max_probe: 0.25,
min_split: 0.01,
..Default::default()
};
let algo = pfw_algo_with_config(2, config.clone());
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let ord = order(&token_a, &token_b, 2_000, OrderSide::Sell);
let result_hi = algo
.find_best_route(gm_hi.graph(), market_hi, None, Some(derived.clone()), &ord)
.await
.unwrap();
assert_eq!(
result_hi.route().swaps().len(),
2,
"PI exit should stop the loop after the first split"
);
let (market_lo, gm_lo) = setup_market_unweighted(vec![
("P1", &token_a, &token_b, cp(500_000)),
("P2", &token_a, &token_b, cp(500_000)),
("P3", &token_a, &token_b, cp(500_000)),
]);
let algo_lo = pfw_algo_with_config(2, config);
let result_lo = algo_lo
.find_best_route(gm_lo.graph(), market_lo, None, Some(derived), &ord)
.await
.unwrap();
assert_eq!(
result_lo.route().swaps().len(),
3,
"without PI exit, all three pools should be used"
);
}
fn pfw_algo(max_hops: usize) -> PathFrankWolfeAlgorithm {
PathFrankWolfeAlgorithm::new(
AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(1000), None).unwrap(),
PathFrankWolfeConfig::default(),
)
}
#[test]
fn test_is_duplicate_path_exact_match() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let candidate =
vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
.with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))];
let alloc = PathAllocation {
hops: vec![HopDescriptor::new("P1".to_string(), token_a, token_b)
.with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))],
flow_fraction: 1.0,
amount_in: BigUint::from(100u64),
amount_out: BigUint::from(200u64),
marginal_price_product: 2.0,
};
assert!(PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
}
#[test]
fn test_is_duplicate_path_shared_prefix() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let token_c = token(0x03, "C");
let zero = BigUint::from(0u64);
let alloc = PathAllocation {
hops: vec![
HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
.with_amounts(zero.clone(), zero.clone()),
HopDescriptor::new("P2".to_string(), token_b.clone(), token_c.clone())
.with_amounts(zero.clone(), zero.clone()),
],
flow_fraction: 1.0,
amount_in: BigUint::from(100u64),
amount_out: BigUint::from(200u64),
marginal_price_product: 1.0,
};
let candidate = vec![
HopDescriptor::new("P1".to_string(), token_a, token_b.clone())
.with_amounts(zero.clone(), zero.clone()),
HopDescriptor::new("P3".to_string(), token_b, token_c)
.with_amounts(zero.clone(), zero.clone()),
];
assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
}
#[test]
fn test_is_duplicate_path_same_pool_different_tokens() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let token_c = token(0x03, "C");
let zero = BigUint::from(0u64);
let alloc = PathAllocation {
hops: vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
.with_amounts(zero.clone(), zero.clone())],
flow_fraction: 1.0,
amount_in: BigUint::from(100u64),
amount_out: BigUint::from(200u64),
marginal_price_product: 2.0,
};
let candidate = vec![HopDescriptor::new("P1".to_string(), token_a, token_c)
.with_amounts(zero.clone(), zero.clone())];
assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
}
#[tokio::test]
async fn test_shared_first_pool_two_outputs() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let token_c = token(0x03, "C");
let (market, graph_manager) = setup_market_unweighted(vec![
(
"P1",
&token_a,
&token_b,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(10_000u64),
reserve_1: BigUint::from(10_000u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
),
(
"P2",
&token_b,
&token_c,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(1_000u64),
reserve_1: BigUint::from(1_500u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
),
(
"P3",
&token_b,
&token_c,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(1_000u64),
reserve_1: BigUint::from(1_000u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
),
]);
let algo = pfw_algo(3);
let probe_amount = BigUint::from(1_000u64);
let ord = order(&token_a, &token_c, 1_000, OrderSide::Sell);
let ctx = algo
.inner
.build_context(graph_manager.graph(), market, None, None, &ord)
.await
.unwrap();
let first_path = algo
.find_candidate_path(&ctx, &[], &probe_amount)
.unwrap();
assert_eq!(first_path[0].descriptor.component_id, "P1");
assert_eq!(first_path[1].descriptor.component_id, "P2");
let first_amount_out = first_path[1].amount_out.clone();
let first_alloc = PathAllocation {
hops: first_path,
flow_fraction: 0.5,
amount_in: probe_amount.clone(),
amount_out: first_amount_out,
marginal_price_product: 1.5,
};
let second_path = algo
.find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
.unwrap();
assert_eq!(second_path[0].descriptor.component_id, "P1");
assert_eq!(second_path[1].descriptor.component_id, "P3");
assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(
&second_path,
std::slice::from_ref(&first_alloc)
));
let second_amount_out = second_path[1].amount_out.clone();
let second_alloc = PathAllocation {
hops: second_path,
flow_fraction: 0.5,
amount_in: probe_amount.clone(),
amount_out: second_amount_out,
marginal_price_product: 1.0,
};
let all_allocs = [first_alloc, second_alloc];
let route = build_split_route(&all_allocs, &ctx.market_data, &ord).unwrap();
let swaps = route.swaps();
assert_eq!(swaps.len(), 3, "expected P1 + P2 + P3 = 3 swaps");
let ids: Vec<&str> = swaps
.iter()
.map(|s| s.component_id())
.collect();
assert_eq!(
ids.iter()
.filter(|&&id| id == "P1")
.count(),
1,
"P1 deduplicated"
);
assert!(ids.contains(&"P2"));
assert!(ids.contains(&"P3"));
assert_eq!(route.total_gas(), BigUint::from(150_000u64));
}
#[tokio::test]
async fn test_duplicate_path_stops_iteration() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let (market, graph_manager) = setup_market_unweighted(vec![(
"P1",
&token_a,
&token_b,
Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
)]);
let algo = pfw_algo(2);
let probe_amount = BigUint::from(100u64);
let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
let ctx = algo
.inner
.build_context(graph_manager.graph(), market, None, None, &ord)
.await
.unwrap();
let first_path = algo
.find_candidate_path(&ctx, &[], &probe_amount)
.unwrap();
assert_eq!(first_path[0].descriptor.component_id, "P1");
let first_alloc = PathAllocation {
hops: first_path,
flow_fraction: 1.0,
amount_in: probe_amount.clone(),
amount_out: BigUint::from(200u64),
marginal_price_product: 2.0,
};
let second_path = algo
.find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
.unwrap();
assert!(PathFrankWolfeAlgorithm::is_duplicate_path(
&second_path,
std::slice::from_ref(&first_alloc)
));
}
#[test]
fn test_with_zero_gas_zeroes_gas_keeps_amounts() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let sim = MockProtocolSim::new(2.0).with_gas(50_000);
let overrides = MarketOverrides::empty()
.with_override("P1".to_string(), Box::new(sim.clone()))
.with_zero_gas("P1".to_string(), token_a.address.clone(), token_b.address.clone());
let result = overrides
.get(&"P1".to_string())
.unwrap()
.get_amount_out(BigUint::from(100u64), &token_a, &token_b)
.unwrap();
assert_eq!(result.amount, BigUint::from(200u64), "amount unaffected");
assert_eq!(result.gas, BigUint::ZERO, "gas zeroed by with_zero_gas");
}
fn pfw_algo_with_config(
max_hops: usize,
pfw_config: PathFrankWolfeConfig,
) -> PathFrankWolfeAlgorithm {
PathFrankWolfeAlgorithm::new(
AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(5000), None).unwrap(),
pfw_config,
)
}
#[tokio::test]
async fn test_single_path_no_split() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let (market, graph_manager) = setup_market_unweighted(vec![(
"P1",
&token_a,
&token_b,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(10_000u64),
reserve_1: BigUint::from(10_000u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
)]);
let algo = pfw_algo(2);
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let ord = order(&token_a, &token_b, 1_000, OrderSide::Sell);
let result = algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
let swaps = result.route().swaps();
assert_eq!(swaps.len(), 1, "single path, single swap");
assert_eq!(swaps[0].component_id(), "P1");
}
#[tokio::test]
async fn test_two_parallel_pools_symmetric() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(reserve),
reserve_1: BigUint::from(reserve),
gas: 50_000,
})
};
let (market, graph_manager) = setup_market_unweighted(vec![
("P1", &token_a, &token_b, cp(100_000)),
("P2", &token_a, &token_b, cp(100_000)),
]);
let algo = pfw_algo_with_config(
2,
PathFrankWolfeConfig {
max_paths: 4,
max_probe: 0.25,
min_split: 0.01,
..Default::default()
},
);
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
let result = algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
let swaps = result.route().swaps();
assert_eq!(swaps.len(), 2, "should use both pools");
let ids: Vec<&str> = swaps
.iter()
.map(|s| s.component_id())
.collect();
assert!(ids.contains(&"P1"));
assert!(ids.contains(&"P2"));
let amounts: Vec<f64> = swaps
.iter()
.map(|s| s.amount_in().to_f64().unwrap())
.collect();
let ratio = amounts[0] / amounts[1];
assert!(
(0.8..=1.2).contains(&ratio),
"expected roughly equal split, got ratio {ratio} (amounts: {amounts:?})"
);
}
#[tokio::test]
async fn test_two_parallel_pools_asymmetric() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(reserve),
reserve_1: BigUint::from(reserve),
gas: 50_000,
})
};
let (market, graph_manager) = setup_market_unweighted(vec![
("deep", &token_a, &token_b, cp(200_000)),
("shallow", &token_a, &token_b, cp(50_000)),
]);
let algo = pfw_algo_with_config(
2,
PathFrankWolfeConfig {
max_paths: 4,
max_probe: 0.5,
min_split: 0.01,
..Default::default()
},
);
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
let result = algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
let swaps = result.route().swaps();
assert_eq!(swaps.len(), 2, "should use both pools");
let deep_swap = swaps
.iter()
.find(|s| s.component_id() == "deep")
.unwrap();
let shallow_swap = swaps
.iter()
.find(|s| s.component_id() == "shallow")
.unwrap();
assert!(
deep_swap.amount_in() > shallow_swap.amount_in(),
"deep pool should get more flow: deep={}, shallow={}",
deep_swap.amount_in(),
shallow_swap.amount_in()
);
}
#[tokio::test]
async fn test_split_vs_single_route() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(reserve),
reserve_1: BigUint::from(reserve),
gas: 50_000,
})
};
let (market, graph_manager) = setup_market_unweighted(vec![
("P1", &token_a, &token_b, cp(100_000)),
("P2", &token_a, &token_b, cp(100_000)),
]);
let algo = pfw_algo_with_config(
2,
PathFrankWolfeConfig {
max_paths: 4,
max_probe: 0.25,
min_split: 0.01,
..Default::default()
},
);
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let ord = order(&token_a, &token_b, 50_000, OrderSide::Sell);
let split_result = algo
.find_best_route(
graph_manager.graph(),
market.clone(),
None,
Some(derived.clone()),
&ord,
)
.await
.unwrap();
let single_algo =
pfw_algo_with_config(2, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
let single_result = single_algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
assert!(
split_result.net_amount_out() > single_result.net_amount_out(),
"split output ({}) should beat single ({})",
split_result.net_amount_out(),
single_result.net_amount_out()
);
}
#[tokio::test]
async fn test_three_paths_discovered() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(reserve),
reserve_1: BigUint::from(reserve),
gas: 50_000,
})
};
let (market, graph_manager) = setup_market_unweighted(vec![
("P1", &token_a, &token_b, cp(100_000)),
("P2", &token_a, &token_b, cp(80_000)),
("P3", &token_a, &token_b, cp(60_000)),
]);
let algo = pfw_algo_with_config(
2,
PathFrankWolfeConfig {
max_paths: 5,
max_probe: 0.5,
min_split: 0.01,
line_search_evals: 16,
},
);
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
let result = algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
let swaps = result.route().swaps();
let ids: Vec<&str> = swaps
.iter()
.map(|s| s.component_id())
.collect();
assert_eq!(ids.len(), 3, "expected 3 paths, got {ids:?}");
assert!(ids.contains(&"P1"), "missing P1");
assert!(ids.contains(&"P2"), "missing P2");
assert!(ids.contains(&"P3"), "missing P3");
}
#[tokio::test]
async fn test_shared_pool_degradation() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let token_c = token(0x03, "C");
let (market, graph_manager) = setup_market_unweighted(vec![
(
"P1",
&token_a,
&token_b,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(100_000u64),
reserve_1: BigUint::from(100_000u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
),
(
"P2",
&token_a,
&token_b,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(100_000u64),
reserve_1: BigUint::from(100_000u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
),
(
"P_shared",
&token_b,
&token_c,
Box::new(ConstantProductSim {
reserve_0: BigUint::from(200_000u64),
reserve_1: BigUint::from(200_000u64),
gas: 50_000,
}) as Box<dyn ProtocolSim>,
),
]);
let algo = pfw_algo_with_config(
3,
PathFrankWolfeConfig {
max_paths: 4,
max_probe: 0.5,
min_split: 0.01,
..Default::default()
},
);
let derived = derived_with_token_prices(&[&token_a, &token_b, &token_c]);
let ord = order(&token_a, &token_c, 20_000, OrderSide::Sell);
let result = algo
.find_best_route(
graph_manager.graph(),
market.clone(),
None,
Some(derived.clone()),
&ord,
)
.await
.unwrap();
let swaps = result.route().swaps();
let ids: Vec<&str> = swaps
.iter()
.map(|s| s.component_id())
.collect();
assert!(ids.contains(&"P1") && ids.contains(&"P2"), "should use both entry pools");
assert!(ids.contains(&"P_shared"), "must use shared B→C pool");
let single_algo =
pfw_algo_with_config(3, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
let single_result = single_algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
assert!(
result.net_amount_out() > single_result.net_amount_out(),
"split ({}) should be > single ({})",
result.net_amount_out(),
single_result.net_amount_out()
);
}
#[tokio::test]
async fn test_timeout_mid_iteration() {
let token_a = token(0x01, "A");
let token_b = token(0x02, "B");
let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
Box::new(ConstantProductSim {
reserve_0: BigUint::from(reserve),
reserve_1: BigUint::from(reserve),
gas: 50_000,
})
};
let pool_names: [&str; 8] = ["P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7"];
let pfw_config =
PathFrankWolfeConfig { max_paths: 8, min_split: 0.001, ..Default::default() };
let ord = order(&token_a, &token_b, 80_000, OrderSide::Sell);
let pools: Vec<_> = pool_names
.iter()
.map(|id| (*id, &token_a, &token_b, cp(100_000)))
.collect();
let (market, graph_manager) = setup_market_unweighted(pools);
let generous_algo = pfw_algo_with_config(2, pfw_config.clone());
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let generous_result = generous_algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
let generous_swaps = generous_result.route().swaps().len();
let pools: Vec<_> = pool_names
.iter()
.map(|id| (*id, &token_a, &token_b, cp(100_000)))
.collect();
let (market, graph_manager) = setup_market_unweighted(pools);
let timeout_algo = PathFrankWolfeAlgorithm::new(
AlgorithmConfig::new(1, 2, StdDuration::from_millis(1), None).unwrap(),
pfw_config,
);
let derived = derived_with_token_prices(&[&token_a, &token_b]);
let timeout_result = timeout_algo
.find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
.await
.unwrap();
let timeout_swaps = timeout_result.route().swaps().len();
assert!(
!timeout_result
.route()
.swaps()
.is_empty(),
"timed-out result must still contain at least one swap"
);
assert!(
timeout_swaps < generous_swaps,
"timed-out result ({timeout_swaps} swaps) should use fewer paths \
than generous result ({generous_swaps} swaps)"
);
}
}