1use std::time::{Duration, Instant};
15
16use num_bigint::{BigInt, BigUint};
17use num_traits::{ToPrimitive, Zero};
18use tracing::{debug, warn};
19use tycho_simulation::tycho_core::models::Address;
20
21use super::{
22 bellman_ford::{BellmanFordContext, FindRouteOptions},
23 split_primitives::{
24 build_post_swap_overrides, build_split_route, compute_marginal_price_product,
25 evaluate_total_output, golden_section_search, normalize_fractions, simulate_path,
26 split_amount, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
27 },
28 Algorithm, AlgorithmConfig, AlgorithmError, BellmanFordAlgorithm,
29};
30use crate::{
31 derived::{computation::ComputationRequirements, SharedDerivedDataRef},
32 feed::market_data::{MarketData, StateLabel},
33 graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
34 types::{quote::Order, OrderSide, Route, RouteResult},
35};
36
37#[derive(Debug, Clone)]
39pub struct PathFrankWolfeConfig {
40 pub max_paths: usize,
42 pub max_probe: f64,
44 pub min_split: f64,
46 pub line_search_evals: usize,
48}
49
50impl Default for PathFrankWolfeConfig {
51 fn default() -> Self {
52 Self { max_paths: 4, max_probe: 0.25, min_split: 0.05, line_search_evals: 12 }
53 }
54}
55
56pub struct PathFrankWolfeAlgorithm {
59 inner: BellmanFordAlgorithm,
60 config: PathFrankWolfeConfig,
61}
62
63impl PathFrankWolfeAlgorithm {
64 pub(crate) fn new(algorithm_config: AlgorithmConfig, config: PathFrankWolfeConfig) -> Self {
66 let inner = BellmanFordAlgorithm::with_config(algorithm_config);
67 Self { inner, config }
68 }
69}
70
71impl Default for PathFrankWolfeAlgorithm {
72 fn default() -> Self {
73 Self::new(AlgorithmConfig::default(), PathFrankWolfeConfig::default())
74 }
75}
76
77impl PathFrankWolfeAlgorithm {
78 fn compute_probe_amount(
83 &self,
84 total_amount: &BigUint,
85 price_impact: f64,
86 gas_cost_output_tokens: f64,
87 ) -> Option<BigUint> {
88 if price_impact <= 0.0 {
89 return None;
90 }
91 let gas_floor = gas_cost_output_tokens / price_impact;
92
93 let probe_amount = BigUint::from(gas_floor.ceil() as u128);
94 let (max_probe_amount, _remainder) = split_amount(total_amount, self.config.max_probe);
95 if probe_amount > max_probe_amount {
96 return None;
97 }
98
99 Some(probe_amount)
100 }
101
102 fn compute_average_price_impact(paths: &[PathAllocation]) -> Result<f64, AlgorithmError> {
109 let mut weighted_price_impact = 0.0;
110 for path in paths {
111 let first_hop = path
112 .hops
113 .first()
114 .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))?;
115 let last_hop = path
116 .hops
117 .last()
118 .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))?;
119
120 let amount_in = path.amount_in.to_f64().ok_or_else(|| {
121 AlgorithmError::Other(format!("amount_in too large for f64: {}", path.amount_in))
122 })?;
123 let amount_out = path
124 .amount_out
125 .to_f64()
126 .ok_or_else(|| {
127 AlgorithmError::Other(format!(
128 "amount_out too large for f64: {}",
129 path.amount_out
130 ))
131 })?;
132 if amount_in <= 0.0 {
133 return Err(AlgorithmError::Other(format!("non-positive amount_in ({amount_in})")));
134 }
135 if path.marginal_price_product <= 0.0 {
136 return Err(AlgorithmError::Other(format!(
137 "non-positive marginal_price_product ({})",
138 path.marginal_price_product
139 )));
140 }
141
142 let amount_in_decimal =
145 amount_in / 10f64.powi(first_hop.descriptor.token_in.decimals as i32);
146 let amount_out_decimal =
147 amount_out / 10f64.powi(last_hop.descriptor.token_out.decimals as i32);
148 let ideal_out = amount_in_decimal * path.marginal_price_product;
149 let price_impact = 1.0 - amount_out_decimal / ideal_out;
150 weighted_price_impact += path.flow_fraction * price_impact;
151 }
152 Ok(weighted_price_impact)
153 }
154
155 pub(crate) fn find_candidate_path(
167 &self,
168 ctx: &BellmanFordContext,
169 current_allocations: &[PathAllocation],
170 probe_amount: &BigUint,
171 ) -> Result<Vec<SimulatedHop>, AlgorithmError> {
172 let mut overrides = build_post_swap_overrides(current_allocations, &ctx.market_data)?;
173
174 for alloc in current_allocations {
180 for hop in &alloc.hops {
181 overrides = overrides.with_zero_gas(
182 hop.descriptor.component_id.clone(),
183 hop.descriptor.token_in.address.clone(),
184 hop.descriptor.token_out.address.clone(),
185 );
186 }
187 }
188
189 let token_in = ctx
190 .node_address
191 .get(&ctx.token_in_node)
192 .cloned()
193 .ok_or_else(|| AlgorithmError::DataNotFound {
194 kind: "token_in node index",
195 id: Some(format!("{:?}", ctx.token_in_node)),
196 })?;
197 let token_out = ctx
198 .node_address
199 .get(&ctx.token_out_node)
200 .cloned()
201 .ok_or_else(|| AlgorithmError::DataNotFound {
202 kind: "token_out node index",
203 id: Some(format!("{:?}", ctx.token_out_node)),
204 })?;
205 let probe_order = Order::new(
206 token_in,
207 token_out,
208 probe_amount.clone(),
209 OrderSide::Sell,
210 Default::default(),
211 );
212
213 let result =
214 self.inner
215 .find_single_route(ctx, &probe_order, FindRouteOptions { overrides })?;
216
217 let route = result.route();
218 let tokens = route.tokens();
219 route
220 .swaps()
221 .iter()
222 .map(|swap| {
223 let token_in = tokens
224 .get(swap.token_in())
225 .cloned()
226 .ok_or_else(|| AlgorithmError::DataNotFound {
227 kind: "token",
228 id: Some(format!("{:?}", swap.token_in())),
229 })?;
230 let token_out = tokens
231 .get(swap.token_out())
232 .cloned()
233 .ok_or_else(|| AlgorithmError::DataNotFound {
234 kind: "token",
235 id: Some(format!("{:?}", swap.token_out())),
236 })?;
237 Ok(SimulatedHop {
238 descriptor: HopDescriptor::new(
239 swap.component_id().to_string(),
240 token_in,
241 token_out,
242 ),
243 amount_out: swap.amount_out().clone(),
244 gas: swap.gas_estimate().clone(),
245 })
246 })
247 .collect()
248 }
249
250 fn gas_cost_output_tokens(
252 route: &Route,
253 ctx: &BellmanFordContext,
254 ) -> Result<f64, AlgorithmError> {
255 let last_swap = route
256 .swaps()
257 .last()
258 .ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
259 Ok(Self::gas_units_to_output_tokens(&route.total_gas(), last_swap.token_out(), ctx))
260 }
261
262 fn gas_units_to_output_tokens(
267 gas: &BigUint,
268 output_token: &Address,
269 ctx: &BellmanFordContext,
270 ) -> f64 {
271 let gas_price = match &ctx.gas_price_wei {
272 Some(gp) if !gp.is_zero() => gp,
273 _ => return 0.0,
274 };
275 let price = match ctx
276 .token_prices
277 .as_ref()
278 .and_then(|tp| tp.get(output_token))
279 {
280 Some(p) if !p.denominator.is_zero() => p,
281 _ => return 0.0,
282 };
283 let gas_cost_wei = gas * gas_price;
284 let gas_cost_tokens = &gas_cost_wei * &price.numerator / &price.denominator;
285 gas_cost_tokens.to_f64().unwrap_or(0.0)
286 }
287
288 fn route_to_allocation(
290 route: &Route,
291 order: &Order,
292 ctx: &BellmanFordContext,
293 ) -> Result<PathAllocation, AlgorithmError> {
294 let tokens = route.tokens();
295 let hops: Vec<SimulatedHop> = route
296 .swaps()
297 .iter()
298 .map(|swap| {
299 let token_in = tokens
300 .get(swap.token_in())
301 .cloned()
302 .ok_or_else(|| AlgorithmError::DataNotFound {
303 kind: "token",
304 id: Some(format!("{:?}", swap.token_in())),
305 })?;
306 let token_out = tokens
307 .get(swap.token_out())
308 .cloned()
309 .ok_or_else(|| AlgorithmError::DataNotFound {
310 kind: "token",
311 id: Some(format!("{:?}", swap.token_out())),
312 })?;
313 Ok(SimulatedHop {
314 descriptor: HopDescriptor::new(
315 swap.component_id().to_string(),
316 token_in,
317 token_out,
318 ),
319 amount_out: swap.amount_out().clone(),
320 gas: swap.gas_estimate().clone(),
321 })
322 })
323 .collect::<Result<_, AlgorithmError>>()?;
324
325 if hops.is_empty() {
326 return Err(AlgorithmError::DataNotFound {
327 kind: "swap",
328 id: Some("route contains no swaps".to_string()),
329 });
330 }
331
332 let descriptors: Vec<HopDescriptor> = hops
333 .iter()
334 .map(|h| h.descriptor.clone())
335 .collect();
336 let overrides = MarketOverrides::empty();
337 let marginal_price_product =
338 compute_marginal_price_product(&descriptors, &ctx.market_data, &overrides)?;
339 let amount_out = hops
340 .last()
341 .map(|h| h.amount_out.clone())
342 .unwrap_or_default();
343
344 Ok(PathAllocation {
345 hops,
346 flow_fraction: 1.0,
347 amount_in: order.amount().clone(),
348 amount_out,
349 marginal_price_product,
350 })
351 }
352
353 fn optimize_step_size(
360 &self,
361 current_allocations: &[PathAllocation],
362 candidate: &[SimulatedHop],
363 total_amount: &BigUint,
364 ctx: &BellmanFordContext,
365 ) -> f64 {
366 let existing_descriptors: Vec<Vec<HopDescriptor>> = current_allocations
367 .iter()
368 .map(|a| {
369 a.hops
370 .iter()
371 .map(|h| h.descriptor.clone())
372 .collect()
373 })
374 .collect();
375 let candidate_descriptors: Vec<HopDescriptor> = candidate
376 .iter()
377 .map(|h| h.descriptor.clone())
378 .collect();
379 let Some(last_hop) = candidate_descriptors.last() else {
380 return 0.0;
381 };
382 let output_token = last_hop.token_out.address.clone();
383 let overrides = MarketOverrides::empty();
384
385 let evaluate_split = |step_size: f64| -> f64 {
390 let mut trial_fractions: Vec<f64> = current_allocations
391 .iter()
392 .map(|a| a.flow_fraction * (1.0 - step_size))
393 .collect();
394 let mut trial_paths: Vec<&[HopDescriptor]> = existing_descriptors
395 .iter()
396 .map(|v| v.as_slice())
397 .collect();
398 if step_size > 0.0 {
399 trial_fractions.push(step_size);
400 trial_paths.push(&candidate_descriptors);
401 }
402
403 match evaluate_total_output(
404 &trial_paths,
405 &trial_fractions,
406 total_amount,
407 &ctx.market_data,
408 &overrides,
409 ) {
410 Ok((total_output, gas)) => {
411 let gross = total_output.to_f64().unwrap_or(0.0);
412 gross -
413 Self::gas_units_to_output_tokens(&BigUint::from(gas), &output_token, ctx)
414 }
415 Err(_) => 0.0,
416 }
417 };
418
419 let step_size =
420 golden_section_search(evaluate_split, 0.0, 1.0, self.config.line_search_evals);
421
422 if evaluate_split(step_size) <= evaluate_split(0.0) {
426 return 0.0;
427 }
428 step_size
429 }
430
431 fn apply_step(
437 &self,
438 allocations: &mut Vec<PathAllocation>,
439 candidate: &[SimulatedHop],
440 step_size: f64,
441 total_amount: &BigUint,
442 ctx: &BellmanFordContext,
443 ) -> Result<(), AlgorithmError> {
444 for alloc in allocations.iter_mut() {
445 alloc.flow_fraction *= 1.0 - step_size;
446 }
447
448 allocations.push(PathAllocation {
449 hops: candidate.to_vec(),
450 flow_fraction: step_size,
451 amount_in: BigUint::zero(),
452 amount_out: BigUint::zero(),
453 marginal_price_product: 0.0,
454 });
455
456 allocations.retain(|a| a.flow_fraction >= self.config.min_split);
457
458 let mut remaining_fractions: Vec<f64> = allocations
459 .iter()
460 .map(|a| a.flow_fraction)
461 .collect();
462 normalize_fractions(&mut remaining_fractions)
463 .map_err(|e| AlgorithmError::Other(e.to_string()))?;
464 let mut post_swap = MarketOverrides::empty();
465 for (alloc, &frac) in allocations
466 .iter_mut()
467 .zip(remaining_fractions.iter())
468 {
469 alloc.flow_fraction = frac;
470 let (alloc_amount_in, _) = split_amount(total_amount, frac);
471 let hop_descriptors: Vec<HopDescriptor> = alloc
472 .hops
473 .iter()
474 .map(|h| h.descriptor.clone())
475 .collect();
476 let sim =
477 simulate_path(&hop_descriptors, &alloc_amount_in, &ctx.market_data, &post_swap)?;
478 alloc.amount_in = alloc_amount_in;
479 alloc.amount_out = sim.amount_out;
480 alloc.marginal_price_product = sim.marginal_price_product;
481
482 for (hop, (amount_out, gas)) in alloc
485 .hops
486 .iter_mut()
487 .zip(sim.hop_results)
488 {
489 hop.amount_out = amount_out;
490 hop.gas = gas;
491 }
492
493 for (component_id, state) in sim.post_swap_states {
495 post_swap = post_swap.with_override(component_id, state);
496 }
497 }
498
499 Ok(())
500 }
501
502 fn optimize_split(
508 &self,
509 ctx: &BellmanFordContext,
510 order: &Order,
511 single_path_result: &RouteResult,
512 start: Instant,
513 ) -> Result<Option<RouteResult>, AlgorithmError> {
514 let mut allocations =
515 vec![Self::route_to_allocation(single_path_result.route(), order, ctx)?];
516
517 let gas_cost = Self::gas_cost_output_tokens(single_path_result.route(), ctx)?;
519 let total_amount = order.amount();
520 let initial_pi = Self::compute_average_price_impact(&allocations)?;
521 if self
522 .compute_probe_amount(total_amount, initial_pi, gas_cost)
523 .is_none()
524 {
525 debug!(pi = initial_pi, gas_cost, "price impact too low to justify splitting");
526 return Ok(None);
527 }
528
529 for iteration in 1..self.config.max_paths {
531 if start.elapsed() >= self.timeout() {
532 debug!(iteration, "pfw timeout, returning partial result");
533 break;
534 }
535
536 let pi = Self::compute_average_price_impact(&allocations)?;
537 let probe_amount = match self.compute_probe_amount(total_amount, pi, gas_cost) {
538 Some(p) => p,
539 None => {
540 debug!(iteration, pi, "probe exceeds cap, stopping");
541 break;
542 }
543 };
544
545 let candidate = match self.find_candidate_path(ctx, &allocations, &probe_amount) {
546 Ok(c) => c,
547 Err(e) => {
548 debug!(
549 iteration,
550 ?e,
551 "no additional candidate path found, stopping further searches"
552 );
553 break;
554 }
555 };
556
557 if Self::is_duplicate_path(&candidate, &allocations) {
558 debug!(iteration, "duplicate path, exploration exhausted");
559 break;
560 }
561
562 let step_size = self.optimize_step_size(&allocations, &candidate, total_amount, ctx);
564
565 if step_size < self.config.min_split {
567 debug!(iteration, step_size, "step size below min_split, stopping");
568 break;
569 }
570
571 self.apply_step(&mut allocations, &candidate, step_size, total_amount, ctx)?;
572 debug!(iteration, paths = allocations.len(), step_size, "pfw iteration complete");
573 }
574
575 if allocations.len() <= 1 {
577 return Ok(None);
578 }
579
580 let split_route = build_split_route(&allocations, &ctx.market_data, order)?;
581
582 if let Err(e) = split_route.validate() {
585 debug!(error = %e, "split route failed validation, falling back to single path");
586 return Ok(None);
587 }
588
589 let gas_price = ctx
590 .gas_price_wei
591 .clone()
592 .unwrap_or_default();
593 let split_net = Self::compute_split_net_amount_out(&split_route, ctx)?;
594 let split_pi = match Self::compute_average_price_impact(&allocations) {
600 Ok(pi) => Some(pi),
601 Err(e) => {
602 warn!(error = %e, "failed to compute price impact for split route; omitting from quote");
603 None
604 }
605 };
606 let mut split_result = RouteResult::new(split_route, split_net, gas_price);
607 if let Some(pi) = split_pi {
608 split_result = split_result.with_price_impact(pi);
609 }
610 Ok(Some(split_result))
611 }
612
613 fn compute_split_net_amount_out(
616 route: &Route,
617 ctx: &BellmanFordContext,
618 ) -> Result<BigInt, AlgorithmError> {
619 let last_swap = route
620 .swaps()
621 .last()
622 .ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
623 let output_token = last_swap.token_out();
624 let total_out: BigUint = route
625 .swaps()
626 .iter()
627 .filter(|s| s.token_out() == output_token)
628 .map(|s| s.amount_out().clone())
629 .fold(BigUint::zero(), |acc, x| acc + x);
630
631 let gas_cost = Self::gas_cost_output_tokens(route, ctx)?;
632 let gas_cost_tokens = BigUint::from(gas_cost.ceil() as u128);
633 Ok(BigInt::from(total_out) - BigInt::from(gas_cost_tokens))
634 }
635
636 pub(crate) fn is_duplicate_path(
646 candidate: &[SimulatedHop],
647 existing: &[PathAllocation],
648 ) -> bool {
649 existing.iter().any(|alloc| {
650 alloc.hops.len() == candidate.len() &&
651 alloc
652 .hops
653 .iter()
654 .zip(candidate.iter())
655 .all(|(a, b)| {
656 a.descriptor.component_id == b.descriptor.component_id &&
657 a.descriptor.token_in.address == b.descriptor.token_in.address &&
658 a.descriptor.token_out.address == b.descriptor.token_out.address
659 })
660 })
661 }
662}
663
664impl Algorithm for PathFrankWolfeAlgorithm {
665 type GraphType = StableDiGraph<()>;
666 type GraphManager = PetgraphStableDiGraphManager<()>;
667
668 fn name(&self) -> &str {
669 "path_frank_wolfe"
670 }
671
672 async fn find_best_route(
673 &self,
674 graph: &Self::GraphType,
675 market: MarketData,
676 label: Option<StateLabel>,
677 derived: Option<SharedDerivedDataRef>,
678 order: &Order,
679 ) -> Result<RouteResult, AlgorithmError> {
680 let start = Instant::now();
681 let ctx = self
682 .inner
683 .build_context(graph, market, label, derived, order)
684 .await?;
685
686 let single_path_result =
688 self.inner
689 .find_single_route(&ctx, order, FindRouteOptions::default())?;
690
691 let split_result = match self.optimize_split(&ctx, order, &single_path_result, start) {
695 Ok(result) => result,
696 Err(e) => {
697 debug!(error = ?e, "split optimization failed, falling back to single path");
698 None
699 }
700 };
701
702 match split_result {
704 Some(split) if split.net_amount_out() > single_path_result.net_amount_out() => {
705 debug!(
706 split_net = %split.net_amount_out(),
707 initial_net = %single_path_result.net_amount_out(),
708 "split route beats single path"
709 );
710 Ok(split)
711 }
712 _ => Ok(single_path_result),
713 }
714 }
715
716 fn computation_requirements(&self) -> ComputationRequirements {
717 ComputationRequirements::none()
718 .allow_stale("token_prices")
719 .expect("token_prices requirement conflicts (bug)")
720 .allow_stale("spot_prices")
721 .expect("spot_prices requirement conflicts (bug)")
722 }
723
724 fn timeout(&self) -> Duration {
725 self.inner.timeout()
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use std::{sync::Arc, time::Duration as StdDuration};
732
733 use tokio::sync::RwLock;
734 use tycho_simulation::tycho_common::{
735 models::token::Token,
736 simulation::protocol_sim::{Price, ProtocolSim},
737 };
738
739 use super::*;
740 use crate::{
741 algorithm::{
742 split_primitives::{build_split_route, MarketOverrides},
743 test_utils::{
744 order, setup_market_unweighted, token, token_with_decimals, ConstantProductSim,
745 MockProtocolSim,
746 },
747 AlgorithmConfig,
748 },
749 derived::{types::TokenGasPrices, DerivedData, SharedDerivedDataRef},
750 graph::GraphManager,
751 types::OrderSide,
752 };
753
754 fn dummy_hops(decimals_in: u32, decimals_out: u32) -> Vec<SimulatedHop> {
757 vec![SimulatedHop {
758 descriptor: HopDescriptor::new(
759 "dummy".to_string(),
760 token_with_decimals(0xAA, "IN", decimals_in),
761 token_with_decimals(0xBB, "OUT", decimals_out),
762 ),
763 amount_out: BigUint::ZERO,
764 gas: BigUint::ZERO,
765 }]
766 }
767
768 fn derived_with_token_prices(tokens: &[&Token]) -> SharedDerivedDataRef {
774 let mut prices = TokenGasPrices::new();
775 let price = Price::new(BigUint::from(1u64), BigUint::from(1_000_000u64));
779 for token in tokens {
780 prices.insert(token.address.clone(), price.clone());
781 }
782 let mut derived = DerivedData::new();
783 derived.set_token_prices(prices, vec![], 1, true);
784 Arc::new(RwLock::new(derived))
785 }
786
787 impl PathFrankWolfeAlgorithm {
788 fn pfw_config(&self) -> &PathFrankWolfeConfig {
790 &self.config
791 }
792 }
793
794 #[test]
795 fn test_with_pfw_config_override() {
796 let pfw_config = PathFrankWolfeConfig {
797 max_paths: 8,
798 max_probe: 0.5,
799 min_split: 0.1,
800 line_search_evals: 24,
801 };
802 let algo = PathFrankWolfeAlgorithm::new(AlgorithmConfig::default(), pfw_config);
803
804 assert_eq!(algo.pfw_config().max_paths, 8);
805 assert!((algo.pfw_config().max_probe - 0.5).abs() < f64::EPSILON);
806 assert!((algo.pfw_config().min_split - 0.1).abs() < f64::EPSILON);
807 assert_eq!(algo.pfw_config().line_search_evals, 24);
808 }
809
810 #[test]
813 fn test_probe_amount_low_impact() {
814 let total = BigUint::from(1_000_000u64);
818 let algo = PathFrankWolfeAlgorithm::default();
819
820 let result = algo.compute_probe_amount(&total, 0.001, 100_000.0);
821 assert!(result.is_none());
822 }
823
824 #[test]
825 fn test_probe_amount_scaling() {
826 let total = BigUint::from(10_000_000u64);
830 let algo = PathFrankWolfeAlgorithm::default();
831 let gas_cost = 1000.0;
832
833 let probe_high_pi = algo
834 .compute_probe_amount(&total, 0.10, gas_cost)
835 .unwrap();
836 let probe_low_pi = algo
837 .compute_probe_amount(&total, 0.05, gas_cost)
838 .unwrap();
839
840 assert!(probe_high_pi < probe_low_pi);
841
842 let ratio = probe_high_pi.to_f64().unwrap() / probe_low_pi.to_f64().unwrap();
845 assert!(
846 (ratio - 0.5).abs() < 0.01,
847 "expected ratio ~0.5 (inverse proportionality), got {ratio}"
848 );
849 }
850
851 #[test]
852 fn test_probe_amount_within_cap() {
853 let total = BigUint::from(1_000_000u64);
857 let algo = PathFrankWolfeAlgorithm::default();
858
859 let probe_amount = algo
860 .compute_probe_amount(&total, 0.10, 1000.0)
861 .unwrap();
862 assert_eq!(probe_amount, BigUint::from(10_000u64));
863 }
864
865 #[test]
866 fn test_probe_amount_zero_price_impact() {
867 let total = BigUint::from(1_000_000u64);
868 let algo = PathFrankWolfeAlgorithm::default();
869
870 assert!(algo
871 .compute_probe_amount(&total, 0.0, 1000.0)
872 .is_none());
873 }
874
875 #[test]
878 fn test_average_price_impact_redistribution() {
879 let hops = dummy_hops(18, 18);
883 let iter_0 = [PathAllocation {
884 hops: hops.clone(),
885 flow_fraction: 1.0,
886 amount_in: BigUint::from(100_000u64),
887 amount_out: BigUint::from(181_818u64),
888 marginal_price_product: 2.0,
889 }];
890
891 let iter_1 = [
892 PathAllocation {
893 hops: hops.clone(),
894 flow_fraction: 0.5,
895 amount_in: BigUint::from(50_000u64),
896 amount_out: BigUint::from(95_238u64),
897 marginal_price_product: 2.0,
898 },
899 PathAllocation {
900 hops: hops.clone(),
901 flow_fraction: 0.5,
902 amount_in: BigUint::from(50_000u64),
903 amount_out: BigUint::from(95_238u64),
904 marginal_price_product: 2.0,
905 },
906 ];
907
908 let third = 1.0 / 3.0;
909 let iter_2 = [
910 PathAllocation {
911 hops: hops.clone(),
912 flow_fraction: third,
913 amount_in: BigUint::from(33_333u64),
914 amount_out: BigUint::from(64_514u64),
915 marginal_price_product: 2.0,
916 },
917 PathAllocation {
918 hops: hops.clone(),
919 flow_fraction: third,
920 amount_in: BigUint::from(33_333u64),
921 amount_out: BigUint::from(64_514u64),
922 marginal_price_product: 2.0,
923 },
924 PathAllocation {
925 hops: hops.clone(),
926 flow_fraction: third,
927 amount_in: BigUint::from(33_334u64),
928 amount_out: BigUint::from(64_516u64),
929 marginal_price_product: 2.0,
930 },
931 ];
932
933 let pi_0 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_0).unwrap();
934 let pi_1 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_1).unwrap();
935 let pi_2 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_2).unwrap();
936
937 assert!(pi_1 < pi_0, "price impact should decrease after first split: {pi_1} >= {pi_0}");
938 assert!(pi_2 < pi_1, "price impact should decrease after second split: {pi_2} >= {pi_1}");
939
940 assert!((pi_0 - 0.09091).abs() < 1e-5, "expected ~0.0909, got {pi_0}");
941 assert!((pi_1 - 0.04762).abs() < 1e-5, "expected ~0.0476, got {pi_1}");
942 assert!((pi_2 - 0.03228).abs() < 1e-5, "expected ~0.0323, got {pi_2}");
943 }
944
945 #[test]
946 fn test_average_price_impact_weighting() {
947 let hops = dummy_hops(18, 18);
953 let allocations = [
954 PathAllocation {
955 hops: hops.clone(),
956 flow_fraction: 0.9,
957 amount_in: BigUint::from(1000u64),
958 amount_out: BigUint::from(900u64),
959 marginal_price_product: 1.0,
960 },
961 PathAllocation {
962 hops: hops.clone(),
963 flow_fraction: 0.1,
964 amount_in: BigUint::from(100u64),
965 amount_out: BigUint::from(50u64),
966 marginal_price_product: 1.0,
967 },
968 ];
969
970 let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
971 assert!((pi - 0.14).abs() < 1e-10, "expected 0.14, got {pi}");
972 }
973
974 #[test]
975 fn test_average_price_impact_mixed_decimals() {
976 let hops = dummy_hops(6, 18);
984 let allocations = [PathAllocation {
985 hops,
986 flow_fraction: 1.0,
987 amount_in: BigUint::from(2_000_000_000u64),
988 amount_out: BigUint::from(900_000_000_000_000_000u64),
989 marginal_price_product: 0.0005,
990 }];
991
992 let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
993 assert!((pi - 0.10).abs() < 1e-10, "expected 0.10 for cross-decimal pair, got {pi}");
994 }
995
996 #[tokio::test]
997 async fn test_pi_exit_criterion_with_high_gas() {
998 let token_a = token(0x01, "A");
1016 let token_b = token(0x02, "B");
1017
1018 let cp = |gas: u64| -> Box<dyn ProtocolSim> {
1019 Box::new(ConstantProductSim {
1020 reserve_0: BigUint::from(5_000u64),
1021 reserve_1: BigUint::from(5_000u64),
1022 gas,
1023 })
1024 };
1025
1026 let (market_hi, gm_hi) = setup_market_unweighted(vec![
1028 ("P1", &token_a, &token_b, cp(1_000_000)),
1029 ("P2", &token_a, &token_b, cp(1_000_000)),
1030 ("P3", &token_a, &token_b, cp(1_000_000)),
1031 ]);
1032
1033 let config = PathFrankWolfeConfig {
1034 max_paths: 4,
1035 max_probe: 0.25,
1036 min_split: 0.01,
1037 ..Default::default()
1038 };
1039 let algo = pfw_algo_with_config(2, config.clone());
1040 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1041 let ord = order(&token_a, &token_b, 2_000, OrderSide::Sell);
1042
1043 let result_hi = algo
1044 .find_best_route(gm_hi.graph(), market_hi, None, Some(derived.clone()), &ord)
1045 .await
1046 .unwrap();
1047
1048 assert_eq!(
1049 result_hi.route().swaps().len(),
1050 2,
1051 "PI exit should stop the loop after the first split"
1052 );
1053
1054 let (market_lo, gm_lo) = setup_market_unweighted(vec![
1058 ("P1", &token_a, &token_b, cp(500_000)),
1059 ("P2", &token_a, &token_b, cp(500_000)),
1060 ("P3", &token_a, &token_b, cp(500_000)),
1061 ]);
1062
1063 let algo_lo = pfw_algo_with_config(2, config);
1064 let result_lo = algo_lo
1065 .find_best_route(gm_lo.graph(), market_lo, None, Some(derived), &ord)
1066 .await
1067 .unwrap();
1068
1069 assert_eq!(
1070 result_lo.route().swaps().len(),
1071 3,
1072 "without PI exit, all three pools should be used"
1073 );
1074 }
1075
1076 #[tokio::test]
1077 async fn test_step_size_rejects_gas_losing_candidate() {
1078 let token_a = token(0x01, "A");
1083 let token_b = token(0x02, "B");
1084
1085 let cp = |gas: u64| -> Box<dyn ProtocolSim> {
1086 Box::new(ConstantProductSim {
1087 reserve_0: BigUint::from(100_000u64),
1088 reserve_1: BigUint::from(100_000u64),
1089 gas,
1090 })
1091 };
1092
1093 let (market, graph_manager) = setup_market_unweighted(vec![
1096 ("P1", &token_a, &token_b, cp(50_000)),
1097 ("P2_expensive", &token_a, &token_b, cp(5_000_000_000)),
1098 ("P3_cheap", &token_a, &token_b, cp(50_000)),
1099 ]);
1100
1101 let algo = pfw_algo(2);
1102 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1103 let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1104
1105 let ctx = algo
1106 .inner
1107 .build_context(graph_manager.graph(), market, None, Some(derived), &ord)
1108 .await
1109 .unwrap();
1110
1111 let hop = |pool: &str| {
1112 HopDescriptor::new(pool.to_string(), token_a.clone(), token_b.clone())
1113 .with_amounts(BigUint::ZERO, BigUint::ZERO)
1114 };
1115 let allocations = [PathAllocation {
1116 hops: vec![hop("P1")],
1117 flow_fraction: 1.0,
1118 amount_in: ord.amount().clone(),
1119 amount_out: BigUint::from(9_090u64),
1120 marginal_price_product: 1.0,
1121 }];
1122
1123 let expensive_step =
1124 algo.optimize_step_size(&allocations, &[hop("P2_expensive")], ord.amount(), &ctx);
1125 assert!(
1126 expensive_step < algo.config.min_split,
1127 "gas-losing candidate must not be activated, got step {expensive_step}"
1128 );
1129
1130 let cheap_step =
1131 algo.optimize_step_size(&allocations, &[hop("P3_cheap")], ord.amount(), &ctx);
1132 assert!(
1133 cheap_step >= algo.config.min_split,
1134 "identical cheap pool should get a real allocation, got step {cheap_step}"
1135 );
1136 }
1137
1138 fn pfw_algo(max_hops: usize) -> PathFrankWolfeAlgorithm {
1141 PathFrankWolfeAlgorithm::new(
1142 AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(1000), None).unwrap(),
1143 PathFrankWolfeConfig::default(),
1144 )
1145 }
1146
1147 #[test]
1148 fn test_is_duplicate_path_exact_match() {
1149 let token_a = token(0x01, "A");
1150 let token_b = token(0x02, "B");
1151 let candidate =
1152 vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1153 .with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))];
1154 let alloc = PathAllocation {
1155 hops: vec![HopDescriptor::new("P1".to_string(), token_a, token_b)
1156 .with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))],
1157 flow_fraction: 1.0,
1158 amount_in: BigUint::from(100u64),
1159 amount_out: BigUint::from(200u64),
1160 marginal_price_product: 2.0,
1161 };
1162 assert!(PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1163 }
1164
1165 #[test]
1166 fn test_is_duplicate_path_shared_prefix() {
1167 let token_a = token(0x01, "A");
1172 let token_b = token(0x02, "B");
1173 let token_c = token(0x03, "C");
1174
1175 let zero = BigUint::from(0u64);
1176 let alloc = PathAllocation {
1177 hops: vec![
1178 HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1179 .with_amounts(zero.clone(), zero.clone()),
1180 HopDescriptor::new("P2".to_string(), token_b.clone(), token_c.clone())
1181 .with_amounts(zero.clone(), zero.clone()),
1182 ],
1183 flow_fraction: 1.0,
1184 amount_in: BigUint::from(100u64),
1185 amount_out: BigUint::from(200u64),
1186 marginal_price_product: 1.0,
1187 };
1188
1189 let candidate = vec![
1191 HopDescriptor::new("P1".to_string(), token_a, token_b.clone())
1192 .with_amounts(zero.clone(), zero.clone()),
1193 HopDescriptor::new("P3".to_string(), token_b, token_c)
1194 .with_amounts(zero.clone(), zero.clone()),
1195 ];
1196 assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1197 }
1198
1199 #[test]
1200 fn test_is_duplicate_path_same_pool_different_tokens() {
1201 let token_a = token(0x01, "A");
1206 let token_b = token(0x02, "B");
1207 let token_c = token(0x03, "C");
1208 let zero = BigUint::from(0u64);
1209 let alloc = PathAllocation {
1210 hops: vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1211 .with_amounts(zero.clone(), zero.clone())],
1212 flow_fraction: 1.0,
1213 amount_in: BigUint::from(100u64),
1214 amount_out: BigUint::from(200u64),
1215 marginal_price_product: 2.0,
1216 };
1217 let candidate = vec![HopDescriptor::new("P1".to_string(), token_a, token_c)
1218 .with_amounts(zero.clone(), zero.clone())];
1219 assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1220 }
1221
1222 #[tokio::test]
1223 async fn test_shared_first_pool_two_outputs() {
1224 let token_a = token(0x01, "A");
1236 let token_b = token(0x02, "B");
1237 let token_c = token(0x03, "C");
1238
1239 let (market, graph_manager) = setup_market_unweighted(vec![
1240 (
1241 "P1",
1242 &token_a,
1243 &token_b,
1244 Box::new(ConstantProductSim {
1245 reserve_0: BigUint::from(10_000u64),
1246 reserve_1: BigUint::from(10_000u64),
1247 gas: 50_000,
1248 }) as Box<dyn ProtocolSim>,
1249 ),
1250 (
1251 "P2",
1252 &token_b,
1253 &token_c,
1254 Box::new(ConstantProductSim {
1255 reserve_0: BigUint::from(1_000u64),
1256 reserve_1: BigUint::from(1_500u64),
1257 gas: 50_000,
1258 }) as Box<dyn ProtocolSim>,
1259 ),
1260 (
1261 "P3",
1262 &token_b,
1263 &token_c,
1264 Box::new(ConstantProductSim {
1265 reserve_0: BigUint::from(1_000u64),
1266 reserve_1: BigUint::from(1_000u64),
1267 gas: 50_000,
1268 }) as Box<dyn ProtocolSim>,
1269 ),
1270 ]);
1271
1272 let algo = pfw_algo(3);
1273 let probe_amount = BigUint::from(1_000u64);
1274 let ord = order(&token_a, &token_c, 1_000, OrderSide::Sell);
1275
1276 let ctx = algo
1277 .inner
1278 .build_context(graph_manager.graph(), market, None, None, &ord)
1279 .await
1280 .unwrap();
1281
1282 let first_path = algo
1284 .find_candidate_path(&ctx, &[], &probe_amount)
1285 .unwrap();
1286 assert_eq!(first_path[0].descriptor.component_id, "P1");
1287 assert_eq!(first_path[1].descriptor.component_id, "P2");
1288
1289 let first_amount_out = first_path[1].amount_out.clone();
1290 let first_alloc = PathAllocation {
1291 hops: first_path,
1292 flow_fraction: 0.5,
1293 amount_in: probe_amount.clone(),
1294 amount_out: first_amount_out,
1295 marginal_price_product: 1.5,
1296 };
1297
1298 let second_path = algo
1300 .find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
1301 .unwrap();
1302 assert_eq!(second_path[0].descriptor.component_id, "P1");
1303 assert_eq!(second_path[1].descriptor.component_id, "P3");
1304
1305 assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(
1307 &second_path,
1308 std::slice::from_ref(&first_alloc)
1309 ));
1310
1311 let second_amount_out = second_path[1].amount_out.clone();
1312 let second_alloc = PathAllocation {
1313 hops: second_path,
1314 flow_fraction: 0.5,
1315 amount_in: probe_amount.clone(),
1316 amount_out: second_amount_out,
1317 marginal_price_product: 1.0,
1318 };
1319
1320 let all_allocs = [first_alloc, second_alloc];
1322 let route = build_split_route(&all_allocs, &ctx.market_data, &ord).unwrap();
1323 let swaps = route.swaps();
1324 assert_eq!(swaps.len(), 3, "expected P1 + P2 + P3 = 3 swaps");
1325 let ids: Vec<&str> = swaps
1326 .iter()
1327 .map(|s| s.component_id())
1328 .collect();
1329 assert_eq!(
1330 ids.iter()
1331 .filter(|&&id| id == "P1")
1332 .count(),
1333 1,
1334 "P1 deduplicated"
1335 );
1336 assert!(ids.contains(&"P2"));
1337 assert!(ids.contains(&"P3"));
1338 assert_eq!(route.total_gas(), BigUint::from(150_000u64));
1340 }
1341
1342 #[tokio::test]
1343 async fn test_duplicate_path_stops_iteration() {
1344 let token_a = token(0x01, "A");
1347 let token_b = token(0x02, "B");
1348
1349 let (market, graph_manager) = setup_market_unweighted(vec![(
1350 "P1",
1351 &token_a,
1352 &token_b,
1353 Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
1354 )]);
1355
1356 let algo = pfw_algo(2);
1357 let probe_amount = BigUint::from(100u64);
1358 let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1359
1360 let ctx = algo
1361 .inner
1362 .build_context(graph_manager.graph(), market, None, None, &ord)
1363 .await
1364 .unwrap();
1365
1366 let first_path = algo
1367 .find_candidate_path(&ctx, &[], &probe_amount)
1368 .unwrap();
1369 assert_eq!(first_path[0].descriptor.component_id, "P1");
1370
1371 let first_alloc = PathAllocation {
1372 hops: first_path,
1373 flow_fraction: 1.0,
1374 amount_in: probe_amount.clone(),
1375 amount_out: BigUint::from(200u64),
1376 marginal_price_product: 2.0,
1377 };
1378
1379 let second_path = algo
1381 .find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
1382 .unwrap();
1383 assert!(PathFrankWolfeAlgorithm::is_duplicate_path(
1384 &second_path,
1385 std::slice::from_ref(&first_alloc)
1386 ));
1387 }
1388
1389 #[test]
1390 fn test_with_zero_gas_zeroes_gas_keeps_amounts() {
1391 let token_a = token(0x01, "A");
1392 let token_b = token(0x02, "B");
1393 let sim = MockProtocolSim::new(2.0).with_gas(50_000);
1394
1395 let overrides = MarketOverrides::empty()
1396 .with_override("P1".to_string(), Box::new(sim.clone()))
1397 .with_zero_gas("P1".to_string(), token_a.address.clone(), token_b.address.clone());
1398
1399 let result = overrides
1400 .get(&"P1".to_string())
1401 .unwrap()
1402 .get_amount_out(BigUint::from(100u64), &token_a, &token_b)
1403 .unwrap();
1404
1405 assert_eq!(result.amount, BigUint::from(200u64), "amount unaffected");
1406 assert_eq!(result.gas, BigUint::ZERO, "gas zeroed by with_zero_gas");
1407 }
1408
1409 fn pfw_algo_with_config(
1412 max_hops: usize,
1413 pfw_config: PathFrankWolfeConfig,
1414 ) -> PathFrankWolfeAlgorithm {
1415 PathFrankWolfeAlgorithm::new(
1416 AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(5000), None).unwrap(),
1417 pfw_config,
1418 )
1419 }
1420
1421 #[tokio::test]
1422 async fn test_single_path_no_split() {
1423 let token_a = token(0x01, "A");
1426 let token_b = token(0x02, "B");
1427
1428 let (market, graph_manager) = setup_market_unweighted(vec![(
1429 "P1",
1430 &token_a,
1431 &token_b,
1432 Box::new(ConstantProductSim {
1433 reserve_0: BigUint::from(10_000u64),
1434 reserve_1: BigUint::from(10_000u64),
1435 gas: 50_000,
1436 }) as Box<dyn ProtocolSim>,
1437 )]);
1438
1439 let algo = pfw_algo(2);
1440 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1441 let ord = order(&token_a, &token_b, 1_000, OrderSide::Sell);
1442
1443 let result = algo
1444 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1445 .await
1446 .unwrap();
1447
1448 let swaps = result.route().swaps();
1449 assert_eq!(swaps.len(), 1, "single path, single swap");
1450 assert_eq!(swaps[0].component_id(), "P1");
1451 }
1452
1453 #[tokio::test]
1454 async fn test_two_parallel_pools_symmetric() {
1455 let token_a = token(0x01, "A");
1461 let token_b = token(0x02, "B");
1462
1463 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1464 Box::new(ConstantProductSim {
1465 reserve_0: BigUint::from(reserve),
1466 reserve_1: BigUint::from(reserve),
1467 gas: 50_000,
1468 })
1469 };
1470
1471 let (market, graph_manager) = setup_market_unweighted(vec![
1472 ("P1", &token_a, &token_b, cp(100_000)),
1473 ("P2", &token_a, &token_b, cp(100_000)),
1474 ]);
1475
1476 let algo = pfw_algo_with_config(
1477 2,
1478 PathFrankWolfeConfig {
1479 max_paths: 4,
1480 max_probe: 0.25,
1481 min_split: 0.01,
1482 ..Default::default()
1483 },
1484 );
1485 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1486 let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1487
1488 let result = algo
1489 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1490 .await
1491 .unwrap();
1492
1493 let swaps = result.route().swaps();
1494 assert_eq!(swaps.len(), 2, "should use both pools");
1495 let ids: Vec<&str> = swaps
1496 .iter()
1497 .map(|s| s.component_id())
1498 .collect();
1499 assert!(ids.contains(&"P1"));
1500 assert!(ids.contains(&"P2"));
1501
1502 let amounts: Vec<f64> = swaps
1504 .iter()
1505 .map(|s| s.amount_in().to_f64().unwrap())
1506 .collect();
1507 let ratio = amounts[0] / amounts[1];
1508 assert!(
1509 (0.8..=1.2).contains(&ratio),
1510 "expected roughly equal split, got ratio {ratio} (amounts: {amounts:?})"
1511 );
1512 }
1513
1514 #[tokio::test]
1515 async fn find_best_route_reports_price_impact_for_split() {
1516 let token_a = token(0x01, "A");
1520 let token_b = token(0x02, "B");
1521
1522 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1523 Box::new(ConstantProductSim {
1524 reserve_0: BigUint::from(reserve),
1525 reserve_1: BigUint::from(reserve),
1526 gas: 50_000,
1527 })
1528 };
1529
1530 let (market, graph_manager) = setup_market_unweighted(vec![
1531 ("P1", &token_a, &token_b, cp(100_000)),
1532 ("P2", &token_a, &token_b, cp(100_000)),
1533 ]);
1534
1535 let algo = pfw_algo_with_config(
1536 2,
1537 PathFrankWolfeConfig {
1538 max_paths: 4,
1539 max_probe: 0.25,
1540 min_split: 0.01,
1541 ..Default::default()
1542 },
1543 );
1544 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1545 let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1546
1547 let result = algo
1548 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1549 .await
1550 .unwrap();
1551
1552 assert_eq!(result.route().swaps().len(), 2, "expected a split route");
1553 assert!(result.price_impact().is_some(), "split route should report price impact");
1554 }
1555
1556 #[tokio::test]
1557 async fn test_two_parallel_pools_asymmetric() {
1558 let token_a = token(0x01, "A");
1564 let token_b = token(0x02, "B");
1565
1566 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1567 Box::new(ConstantProductSim {
1568 reserve_0: BigUint::from(reserve),
1569 reserve_1: BigUint::from(reserve),
1570 gas: 50_000,
1571 })
1572 };
1573
1574 let (market, graph_manager) = setup_market_unweighted(vec![
1575 ("deep", &token_a, &token_b, cp(200_000)),
1576 ("shallow", &token_a, &token_b, cp(50_000)),
1577 ]);
1578
1579 let algo = pfw_algo_with_config(
1580 2,
1581 PathFrankWolfeConfig {
1582 max_paths: 4,
1583 max_probe: 0.5,
1584 min_split: 0.01,
1585 ..Default::default()
1586 },
1587 );
1588 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1589 let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1590
1591 let result = algo
1592 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1593 .await
1594 .unwrap();
1595
1596 let swaps = result.route().swaps();
1597 assert_eq!(swaps.len(), 2, "should use both pools");
1598
1599 let deep_swap = swaps
1600 .iter()
1601 .find(|s| s.component_id() == "deep")
1602 .unwrap();
1603 let shallow_swap = swaps
1604 .iter()
1605 .find(|s| s.component_id() == "shallow")
1606 .unwrap();
1607
1608 assert!(
1609 deep_swap.amount_in() > shallow_swap.amount_in(),
1610 "deep pool should get more flow: deep={}, shallow={}",
1611 deep_swap.amount_in(),
1612 shallow_swap.amount_in()
1613 );
1614 }
1615
1616 #[tokio::test]
1617 async fn test_split_vs_single_route() {
1618 let token_a = token(0x01, "A");
1625 let token_b = token(0x02, "B");
1626
1627 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1628 Box::new(ConstantProductSim {
1629 reserve_0: BigUint::from(reserve),
1630 reserve_1: BigUint::from(reserve),
1631 gas: 50_000,
1632 })
1633 };
1634
1635 let (market, graph_manager) = setup_market_unweighted(vec![
1636 ("P1", &token_a, &token_b, cp(100_000)),
1637 ("P2", &token_a, &token_b, cp(100_000)),
1638 ]);
1639
1640 let algo = pfw_algo_with_config(
1641 2,
1642 PathFrankWolfeConfig {
1643 max_paths: 4,
1644 max_probe: 0.25,
1645 min_split: 0.01,
1646 ..Default::default()
1647 },
1648 );
1649 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1651 let ord = order(&token_a, &token_b, 50_000, OrderSide::Sell);
1652
1653 let split_result = algo
1654 .find_best_route(
1655 graph_manager.graph(),
1656 market.clone(),
1657 None,
1658 Some(derived.clone()),
1659 &ord,
1660 )
1661 .await
1662 .unwrap();
1663
1664 let single_algo =
1666 pfw_algo_with_config(2, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1667 let single_result = single_algo
1668 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1669 .await
1670 .unwrap();
1671
1672 assert!(
1673 split_result.net_amount_out() > single_result.net_amount_out(),
1674 "split output ({}) should beat single ({})",
1675 split_result.net_amount_out(),
1676 single_result.net_amount_out()
1677 );
1678 }
1679
1680 #[tokio::test]
1681 async fn test_three_paths_discovered() {
1682 let token_a = token(0x01, "A");
1689 let token_b = token(0x02, "B");
1690
1691 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1692 Box::new(ConstantProductSim {
1693 reserve_0: BigUint::from(reserve),
1694 reserve_1: BigUint::from(reserve),
1695 gas: 50_000,
1696 })
1697 };
1698
1699 let (market, graph_manager) = setup_market_unweighted(vec![
1700 ("P1", &token_a, &token_b, cp(100_000)),
1701 ("P2", &token_a, &token_b, cp(80_000)),
1702 ("P3", &token_a, &token_b, cp(60_000)),
1703 ]);
1704
1705 let algo = pfw_algo_with_config(
1706 2,
1707 PathFrankWolfeConfig {
1708 max_paths: 5,
1709 max_probe: 0.5,
1710 min_split: 0.01,
1711 line_search_evals: 16,
1712 },
1713 );
1714 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1715 let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1716
1717 let result = algo
1718 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1719 .await
1720 .unwrap();
1721
1722 let swaps = result.route().swaps();
1723 let ids: Vec<&str> = swaps
1724 .iter()
1725 .map(|s| s.component_id())
1726 .collect();
1727 assert_eq!(ids.len(), 3, "expected 3 paths, got {ids:?}");
1728 assert!(ids.contains(&"P1"), "missing P1");
1729 assert!(ids.contains(&"P2"), "missing P2");
1730 assert!(ids.contains(&"P3"), "missing P3");
1731 }
1732
1733 #[tokio::test]
1734 async fn test_shared_pool_degradation() {
1735 let token_a = token(0x01, "A");
1745 let token_b = token(0x02, "B");
1746 let token_c = token(0x03, "C");
1747
1748 let (market, graph_manager) = setup_market_unweighted(vec![
1749 (
1750 "P1",
1751 &token_a,
1752 &token_b,
1753 Box::new(ConstantProductSim {
1754 reserve_0: BigUint::from(100_000u64),
1755 reserve_1: BigUint::from(100_000u64),
1756 gas: 50_000,
1757 }) as Box<dyn ProtocolSim>,
1758 ),
1759 (
1760 "P2",
1761 &token_a,
1762 &token_b,
1763 Box::new(ConstantProductSim {
1764 reserve_0: BigUint::from(100_000u64),
1765 reserve_1: BigUint::from(100_000u64),
1766 gas: 50_000,
1767 }) as Box<dyn ProtocolSim>,
1768 ),
1769 (
1770 "P_shared",
1771 &token_b,
1772 &token_c,
1773 Box::new(ConstantProductSim {
1774 reserve_0: BigUint::from(200_000u64),
1775 reserve_1: BigUint::from(200_000u64),
1776 gas: 50_000,
1777 }) as Box<dyn ProtocolSim>,
1778 ),
1779 ]);
1780
1781 let algo = pfw_algo_with_config(
1782 3,
1783 PathFrankWolfeConfig {
1784 max_paths: 4,
1785 max_probe: 0.5,
1786 min_split: 0.01,
1787 ..Default::default()
1788 },
1789 );
1790 let derived = derived_with_token_prices(&[&token_a, &token_b, &token_c]);
1791 let ord = order(&token_a, &token_c, 20_000, OrderSide::Sell);
1792
1793 let result = algo
1794 .find_best_route(
1795 graph_manager.graph(),
1796 market.clone(),
1797 None,
1798 Some(derived.clone()),
1799 &ord,
1800 )
1801 .await
1802 .unwrap();
1803
1804 let swaps = result.route().swaps();
1805 let ids: Vec<&str> = swaps
1806 .iter()
1807 .map(|s| s.component_id())
1808 .collect();
1809
1810 assert!(ids.contains(&"P1") && ids.contains(&"P2"), "should use both entry pools");
1812 assert!(ids.contains(&"P_shared"), "must use shared B→C pool");
1813
1814 let single_algo =
1817 pfw_algo_with_config(3, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1818 let single_result = single_algo
1819 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1820 .await
1821 .unwrap();
1822
1823 assert!(
1824 result.net_amount_out() > single_result.net_amount_out(),
1825 "split ({}) should be > single ({})",
1826 result.net_amount_out(),
1827 single_result.net_amount_out()
1828 );
1829 }
1830
1831 #[tokio::test]
1832 async fn test_timeout_mid_iteration() {
1833 let token_a = token(0x01, "A");
1843 let token_b = token(0x02, "B");
1844
1845 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1846 Box::new(ConstantProductSim {
1847 reserve_0: BigUint::from(reserve),
1848 reserve_1: BigUint::from(reserve),
1849 gas: 50_000,
1850 })
1851 };
1852
1853 let pool_names: [&str; 8] = ["P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7"];
1854
1855 let pfw_config =
1856 PathFrankWolfeConfig { max_paths: 8, min_split: 0.001, ..Default::default() };
1857 let ord = order(&token_a, &token_b, 80_000, OrderSide::Sell);
1858
1859 let pools: Vec<_> = pool_names
1861 .iter()
1862 .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1863 .collect();
1864 let (market, graph_manager) = setup_market_unweighted(pools);
1865 let generous_algo = pfw_algo_with_config(2, pfw_config.clone());
1866 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1867 let generous_result = generous_algo
1868 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1869 .await
1870 .unwrap();
1871 let generous_swaps = generous_result.route().swaps().len();
1872
1873 let pools: Vec<_> = pool_names
1875 .iter()
1876 .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1877 .collect();
1878 let (market, graph_manager) = setup_market_unweighted(pools);
1879 let timeout_algo = PathFrankWolfeAlgorithm::new(
1880 AlgorithmConfig::new(1, 2, StdDuration::from_millis(1), None).unwrap(),
1881 pfw_config,
1882 );
1883 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1884 let timeout_result = timeout_algo
1885 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1886 .await
1887 .unwrap();
1888 let timeout_swaps = timeout_result.route().swaps().len();
1889
1890 assert!(
1891 !timeout_result
1892 .route()
1893 .swaps()
1894 .is_empty(),
1895 "timed-out result must still contain at least one swap"
1896 );
1897 assert!(
1898 timeout_swaps < generous_swaps,
1899 "timed-out result ({timeout_swaps} swaps) should use fewer paths \
1900 than generous result ({generous_swaps} swaps)"
1901 );
1902 }
1903}