1use std::time::{Duration, Instant};
9
10use num_bigint::{BigInt, BigUint};
11use num_traits::{ToPrimitive, Zero};
12use tracing::debug;
13
14use super::{
15 bellman_ford::{BellmanFordContext, FindRouteOptions},
16 split_primitives::{
17 build_post_swap_overrides, build_split_route, compute_marginal_price_product,
18 evaluate_total_output, golden_section_search, normalize_fractions, simulate_path,
19 split_amount, HopDescriptor, MarketOverrides, PathAllocation, SimulatedHop,
20 },
21 Algorithm, AlgorithmConfig, AlgorithmError, BellmanFordAlgorithm,
22};
23use crate::{
24 derived::{computation::ComputationRequirements, SharedDerivedDataRef},
25 feed::market_data::{MarketData, StateLabel},
26 graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
27 types::{quote::Order, OrderSide, Route, RouteResult},
28};
29
30#[derive(Debug, Clone)]
32pub struct PathFrankWolfeConfig {
33 pub max_paths: usize,
35 pub max_probe: f64,
37 pub min_split: f64,
39 pub line_search_evals: usize,
41}
42
43impl Default for PathFrankWolfeConfig {
44 fn default() -> Self {
45 Self { max_paths: 4, max_probe: 0.25, min_split: 0.05, line_search_evals: 12 }
46 }
47}
48
49pub struct PathFrankWolfeAlgorithm {
52 inner: BellmanFordAlgorithm,
53 config: PathFrankWolfeConfig,
54}
55
56impl PathFrankWolfeAlgorithm {
57 pub(crate) fn new(algorithm_config: AlgorithmConfig, config: PathFrankWolfeConfig) -> Self {
59 let inner = BellmanFordAlgorithm::with_config(algorithm_config);
60 Self { inner, config }
61 }
62}
63
64impl Default for PathFrankWolfeAlgorithm {
65 fn default() -> Self {
66 Self::new(AlgorithmConfig::default(), PathFrankWolfeConfig::default())
67 }
68}
69
70impl PathFrankWolfeAlgorithm {
71 fn compute_probe_amount(
76 &self,
77 total_amount: &BigUint,
78 price_impact: f64,
79 gas_cost_output_tokens: f64,
80 ) -> Option<BigUint> {
81 if price_impact <= 0.0 {
82 return None;
83 }
84 let gas_floor = gas_cost_output_tokens / price_impact;
85
86 let probe_amount = BigUint::from(gas_floor.ceil() as u128);
87 let (max_probe_amount, _remainder) = split_amount(total_amount, self.config.max_probe);
88 if probe_amount > max_probe_amount {
89 return None;
90 }
91
92 Some(probe_amount)
93 }
94
95 fn compute_average_price_impact(paths: &[PathAllocation]) -> Result<f64, AlgorithmError> {
102 let mut weighted_price_impact = 0.0;
103 for path in paths {
104 let first_hop = path
105 .hops
106 .first()
107 .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))?;
108 let last_hop = path
109 .hops
110 .last()
111 .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))?;
112
113 let amount_in = path.amount_in.to_f64().ok_or_else(|| {
114 AlgorithmError::Other(format!("amount_in too large for f64: {}", path.amount_in))
115 })?;
116 let amount_out = path
117 .amount_out
118 .to_f64()
119 .ok_or_else(|| {
120 AlgorithmError::Other(format!(
121 "amount_out too large for f64: {}",
122 path.amount_out
123 ))
124 })?;
125 if amount_in <= 0.0 {
126 return Err(AlgorithmError::Other(format!("non-positive amount_in ({amount_in})")));
127 }
128 if path.marginal_price_product <= 0.0 {
129 return Err(AlgorithmError::Other(format!(
130 "non-positive marginal_price_product ({})",
131 path.marginal_price_product
132 )));
133 }
134
135 let amount_in_decimal =
138 amount_in / 10f64.powi(first_hop.descriptor.token_in.decimals as i32);
139 let amount_out_decimal =
140 amount_out / 10f64.powi(last_hop.descriptor.token_out.decimals as i32);
141 let ideal_out = amount_in_decimal * path.marginal_price_product;
142 let price_impact = 1.0 - amount_out_decimal / ideal_out;
143 weighted_price_impact += path.flow_fraction * price_impact;
144 }
145 Ok(weighted_price_impact)
146 }
147
148 pub(crate) fn find_candidate_path(
160 &self,
161 ctx: &BellmanFordContext,
162 current_allocations: &[PathAllocation],
163 probe_amount: &BigUint,
164 ) -> Result<Vec<SimulatedHop>, AlgorithmError> {
165 let mut overrides = build_post_swap_overrides(current_allocations, &ctx.market_data);
166
167 for alloc in current_allocations {
173 for hop in &alloc.hops {
174 overrides = overrides.with_zero_gas(
175 hop.descriptor.component_id.clone(),
176 hop.descriptor.token_in.address.clone(),
177 hop.descriptor.token_out.address.clone(),
178 );
179 }
180 }
181
182 let token_in = ctx
183 .node_address
184 .get(&ctx.token_in_node)
185 .cloned()
186 .ok_or_else(|| AlgorithmError::DataNotFound {
187 kind: "token_in node index",
188 id: Some(format!("{:?}", ctx.token_in_node)),
189 })?;
190 let token_out = ctx
191 .node_address
192 .get(&ctx.token_out_node)
193 .cloned()
194 .ok_or_else(|| AlgorithmError::DataNotFound {
195 kind: "token_out node index",
196 id: Some(format!("{:?}", ctx.token_out_node)),
197 })?;
198 let probe_order = Order::new(
199 token_in,
200 token_out,
201 probe_amount.clone(),
202 OrderSide::Sell,
203 Default::default(),
204 );
205
206 let result =
207 self.inner
208 .find_single_route(ctx, &probe_order, FindRouteOptions { overrides })?;
209
210 let route = result.route();
211 let tokens = route.tokens();
212 route
213 .swaps()
214 .iter()
215 .map(|swap| {
216 let token_in = tokens
217 .get(swap.token_in())
218 .cloned()
219 .ok_or_else(|| AlgorithmError::DataNotFound {
220 kind: "token",
221 id: Some(format!("{:?}", swap.token_in())),
222 })?;
223 let token_out = tokens
224 .get(swap.token_out())
225 .cloned()
226 .ok_or_else(|| AlgorithmError::DataNotFound {
227 kind: "token",
228 id: Some(format!("{:?}", swap.token_out())),
229 })?;
230 Ok(SimulatedHop {
231 descriptor: HopDescriptor::new(
232 swap.component_id().to_string(),
233 token_in,
234 token_out,
235 ),
236 amount_out: swap.amount_out().clone(),
237 gas: swap.gas_estimate().clone(),
238 })
239 })
240 .collect()
241 }
242
243 fn gas_cost_output_tokens(
245 route: &Route,
246 ctx: &BellmanFordContext,
247 ) -> Result<f64, AlgorithmError> {
248 let gas_price = match &ctx.gas_price_wei {
249 Some(gp) if !gp.is_zero() => gp,
250 _ => return Ok(0.0),
251 };
252 let last_swap = route
253 .swaps()
254 .last()
255 .ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
256 let price = match ctx
257 .token_prices
258 .as_ref()
259 .and_then(|tp| tp.get(last_swap.token_out()))
260 {
261 Some(p) if !p.denominator.is_zero() => p,
262 _ => return Ok(0.0),
263 };
264 let gas_cost_wei = &route.total_gas() * gas_price;
265 let gas_cost_tokens = &gas_cost_wei * &price.numerator / &price.denominator;
266 Ok(gas_cost_tokens.to_f64().unwrap_or(0.0))
267 }
268
269 fn route_to_allocation(
271 route: &Route,
272 order: &Order,
273 ctx: &BellmanFordContext,
274 ) -> Result<PathAllocation, AlgorithmError> {
275 let tokens = route.tokens();
276 let hops: Vec<SimulatedHop> = route
277 .swaps()
278 .iter()
279 .map(|swap| {
280 let token_in = tokens
281 .get(swap.token_in())
282 .cloned()
283 .ok_or_else(|| AlgorithmError::DataNotFound {
284 kind: "token",
285 id: Some(format!("{:?}", swap.token_in())),
286 })?;
287 let token_out = tokens
288 .get(swap.token_out())
289 .cloned()
290 .ok_or_else(|| AlgorithmError::DataNotFound {
291 kind: "token",
292 id: Some(format!("{:?}", swap.token_out())),
293 })?;
294 Ok(SimulatedHop {
295 descriptor: HopDescriptor::new(
296 swap.component_id().to_string(),
297 token_in,
298 token_out,
299 ),
300 amount_out: swap.amount_out().clone(),
301 gas: swap.gas_estimate().clone(),
302 })
303 })
304 .collect::<Result<_, AlgorithmError>>()?;
305
306 if hops.is_empty() {
307 return Err(AlgorithmError::DataNotFound {
308 kind: "swap",
309 id: Some("route contains no swaps".to_string()),
310 });
311 }
312
313 let descriptors: Vec<HopDescriptor> = hops
314 .iter()
315 .map(|h| h.descriptor.clone())
316 .collect();
317 let overrides = MarketOverrides::empty();
318 let marginal_price_product =
319 compute_marginal_price_product(&descriptors, &ctx.market_data, &overrides)?;
320 let amount_out = hops
321 .last()
322 .map(|h| h.amount_out.clone())
323 .unwrap_or_default();
324
325 Ok(PathAllocation {
326 hops,
327 flow_fraction: 1.0,
328 amount_in: order.amount().clone(),
329 amount_out,
330 marginal_price_product,
331 })
332 }
333
334 fn optimize_step_size(
340 &self,
341 current_allocations: &[PathAllocation],
342 candidate: &[SimulatedHop],
343 total_amount: &BigUint,
344 ctx: &BellmanFordContext,
345 ) -> f64 {
346 let existing_descriptors: Vec<Vec<HopDescriptor>> = current_allocations
347 .iter()
348 .map(|a| {
349 a.hops
350 .iter()
351 .map(|h| h.descriptor.clone())
352 .collect()
353 })
354 .collect();
355 let candidate_descriptors: Vec<HopDescriptor> = candidate
356 .iter()
357 .map(|h| h.descriptor.clone())
358 .collect();
359 let overrides = MarketOverrides::empty();
360
361 let evaluate_split = |step_size: f64| -> f64 {
364 let mut trial_fractions: Vec<f64> = current_allocations
365 .iter()
366 .map(|a| a.flow_fraction * (1.0 - step_size))
367 .collect();
368 trial_fractions.push(step_size);
369
370 let mut trial_paths: Vec<&[HopDescriptor]> = existing_descriptors
371 .iter()
372 .map(|v| v.as_slice())
373 .collect();
374 trial_paths.push(&candidate_descriptors);
375
376 match evaluate_total_output(
377 &trial_paths,
378 &trial_fractions,
379 total_amount,
380 &ctx.market_data,
381 &overrides,
382 ) {
383 Ok((total_output, _gas)) => total_output.to_f64().unwrap_or(0.0),
384 Err(_) => 0.0,
385 }
386 };
387
388 golden_section_search(evaluate_split, 0.0, 1.0, self.config.line_search_evals)
389 }
390
391 fn apply_step(
395 &self,
396 allocations: &mut Vec<PathAllocation>,
397 candidate: &[SimulatedHop],
398 step_size: f64,
399 total_amount: &BigUint,
400 ctx: &BellmanFordContext,
401 ) -> Result<(), AlgorithmError> {
402 for alloc in allocations.iter_mut() {
403 alloc.flow_fraction *= 1.0 - step_size;
404 }
405
406 allocations.push(PathAllocation {
407 hops: candidate.to_vec(),
408 flow_fraction: step_size,
409 amount_in: BigUint::zero(),
410 amount_out: BigUint::zero(),
411 marginal_price_product: 0.0,
412 });
413
414 allocations.retain(|a| a.flow_fraction >= self.config.min_split);
415
416 let mut remaining_fractions: Vec<f64> = allocations
417 .iter()
418 .map(|a| a.flow_fraction)
419 .collect();
420 normalize_fractions(&mut remaining_fractions)
421 .map_err(|e| AlgorithmError::Other(e.to_string()))?;
422 let overrides = MarketOverrides::empty();
423 for (alloc, &frac) in allocations
424 .iter_mut()
425 .zip(remaining_fractions.iter())
426 {
427 alloc.flow_fraction = frac;
428 let (alloc_amount_in, _) = split_amount(total_amount, frac);
429 let hop_descriptors: Vec<HopDescriptor> = alloc
430 .hops
431 .iter()
432 .map(|h| h.descriptor.clone())
433 .collect();
434 let sim =
435 simulate_path(&hop_descriptors, &alloc_amount_in, &ctx.market_data, &overrides)?;
436 alloc.amount_in = alloc_amount_in;
437 alloc.amount_out = sim.amount_out;
438 alloc.marginal_price_product = sim.marginal_price_product;
439
440 for (hop, (amount_out, gas)) in alloc
443 .hops
444 .iter_mut()
445 .zip(sim.hop_results)
446 {
447 hop.amount_out = amount_out;
448 hop.gas = gas;
449 }
450 }
451
452 Ok(())
453 }
454
455 fn compute_split_net_amount_out(
458 route: &Route,
459 ctx: &BellmanFordContext,
460 ) -> Result<BigInt, AlgorithmError> {
461 let last_swap = route
462 .swaps()
463 .last()
464 .ok_or_else(|| AlgorithmError::Other("route has no swaps".to_string()))?;
465 let output_token = last_swap.token_out();
466 let total_out: BigUint = route
467 .swaps()
468 .iter()
469 .filter(|s| s.token_out() == output_token)
470 .map(|s| s.amount_out().clone())
471 .fold(BigUint::zero(), |acc, x| acc + x);
472
473 let gas_cost = Self::gas_cost_output_tokens(route, ctx)?;
474 let gas_cost_tokens = BigUint::from(gas_cost.ceil() as u128);
475 Ok(BigInt::from(total_out) - BigInt::from(gas_cost_tokens))
476 }
477
478 pub(crate) fn is_duplicate_path(
488 candidate: &[SimulatedHop],
489 existing: &[PathAllocation],
490 ) -> bool {
491 existing.iter().any(|alloc| {
492 alloc.hops.len() == candidate.len() &&
493 alloc
494 .hops
495 .iter()
496 .zip(candidate.iter())
497 .all(|(a, b)| {
498 a.descriptor.component_id == b.descriptor.component_id &&
499 a.descriptor.token_in.address == b.descriptor.token_in.address &&
500 a.descriptor.token_out.address == b.descriptor.token_out.address
501 })
502 })
503 }
504}
505
506impl Algorithm for PathFrankWolfeAlgorithm {
507 type GraphType = StableDiGraph<()>;
508 type GraphManager = PetgraphStableDiGraphManager<()>;
509
510 fn name(&self) -> &str {
511 "path_frank_wolfe"
512 }
513
514 async fn find_best_route(
515 &self,
516 graph: &Self::GraphType,
517 market: MarketData,
518 label: Option<StateLabel>,
519 derived: Option<SharedDerivedDataRef>,
520 order: &Order,
521 ) -> Result<RouteResult, AlgorithmError> {
522 let start = Instant::now();
523 let ctx = self
524 .inner
525 .build_context(graph, market, label, derived, order)
526 .await?;
527
528 let single_path_result =
530 self.inner
531 .find_single_route(&ctx, order, FindRouteOptions::default())?;
532
533 let mut allocations =
534 vec![Self::route_to_allocation(single_path_result.route(), order, &ctx)?];
535
536 let gas_cost = Self::gas_cost_output_tokens(single_path_result.route(), &ctx)?;
538 let total_amount = order.amount();
539 let initial_pi = Self::compute_average_price_impact(&allocations)?;
540 if self
541 .compute_probe_amount(total_amount, initial_pi, gas_cost)
542 .is_none()
543 {
544 debug!(pi = initial_pi, gas_cost, "price impact too low to justify splitting");
545 return Ok(single_path_result);
546 }
547
548 for iteration in 1..self.config.max_paths {
550 if start.elapsed() >= self.timeout() {
551 debug!(iteration, "pfw timeout, returning partial result");
552 break;
553 }
554
555 let pi = Self::compute_average_price_impact(&allocations)?;
556 let probe_amount = match self.compute_probe_amount(total_amount, pi, gas_cost) {
557 Some(p) => p,
558 None => {
559 debug!(iteration, pi, "probe exceeds cap, stopping");
560 break;
561 }
562 };
563
564 let candidate = match self.find_candidate_path(&ctx, &allocations, &probe_amount) {
565 Ok(c) => c,
566 Err(e) => {
567 debug!(
568 iteration,
569 ?e,
570 "no additional candidate path found, stopping further searches"
571 );
572 break;
573 }
574 };
575
576 if Self::is_duplicate_path(&candidate, &allocations) {
577 debug!(iteration, "duplicate path, exploration exhausted");
578 break;
579 }
580
581 let step_size = self.optimize_step_size(&allocations, &candidate, total_amount, &ctx);
583
584 if step_size < self.config.min_split {
586 debug!(iteration, step_size, "step size below min_split, stopping");
587 break;
588 }
589
590 self.apply_step(&mut allocations, &candidate, step_size, total_amount, &ctx)?;
591 debug!(iteration, paths = allocations.len(), step_size, "pfw iteration complete");
592 }
593
594 if allocations.len() <= 1 {
596 return Ok(single_path_result);
597 }
598
599 let split_route = build_split_route(&allocations, &ctx.market_data, order)?;
601 let gas_price = ctx
602 .gas_price_wei
603 .clone()
604 .unwrap_or_default();
605 let split_net = Self::compute_split_net_amount_out(&split_route, &ctx)?;
606 let split_result = RouteResult::new(split_route, split_net, gas_price);
607
608 if split_result.net_amount_out() > single_path_result.net_amount_out() {
609 debug!(
610 split_net = %split_result.net_amount_out(),
611 initial_net = %single_path_result.net_amount_out(),
612 paths = allocations.len(),
613 "split route beats single path"
614 );
615 Ok(split_result)
616 } else {
617 debug!(
618 split_net = %split_result.net_amount_out(),
619 initial_net = %single_path_result.net_amount_out(),
620 "single path still best"
621 );
622 Ok(single_path_result)
623 }
624 }
625
626 fn computation_requirements(&self) -> ComputationRequirements {
627 ComputationRequirements::none()
628 .allow_stale("token_prices")
629 .expect("token_prices requirement conflicts (bug)")
630 .allow_stale("spot_prices")
631 .expect("spot_prices requirement conflicts (bug)")
632 }
633
634 fn timeout(&self) -> Duration {
635 self.inner.timeout()
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use std::{sync::Arc, time::Duration as StdDuration};
642
643 use tokio::sync::RwLock;
644 use tycho_simulation::tycho_common::{
645 models::token::Token,
646 simulation::protocol_sim::{Price, ProtocolSim},
647 };
648
649 use super::*;
650 use crate::{
651 algorithm::{
652 split_primitives::{build_split_route, MarketOverrides},
653 test_utils::{
654 order, setup_market_unweighted, token, token_with_decimals, ConstantProductSim,
655 MockProtocolSim,
656 },
657 AlgorithmConfig,
658 },
659 derived::{types::TokenGasPrices, DerivedData, SharedDerivedDataRef},
660 graph::GraphManager,
661 types::OrderSide,
662 };
663
664 fn dummy_hops(decimals_in: u32, decimals_out: u32) -> Vec<SimulatedHop> {
667 vec![SimulatedHop {
668 descriptor: HopDescriptor::new(
669 "dummy".to_string(),
670 token_with_decimals(0xAA, "IN", decimals_in),
671 token_with_decimals(0xBB, "OUT", decimals_out),
672 ),
673 amount_out: BigUint::ZERO,
674 gas: BigUint::ZERO,
675 }]
676 }
677
678 fn derived_with_token_prices(tokens: &[&Token]) -> SharedDerivedDataRef {
684 let mut prices = TokenGasPrices::new();
685 let price = Price::new(BigUint::from(1u64), BigUint::from(1_000_000u64));
689 for token in tokens {
690 prices.insert(token.address.clone(), price.clone());
691 }
692 let mut derived = DerivedData::new();
693 derived.set_token_prices(prices, vec![], 1, true);
694 Arc::new(RwLock::new(derived))
695 }
696
697 impl PathFrankWolfeAlgorithm {
698 fn pfw_config(&self) -> &PathFrankWolfeConfig {
700 &self.config
701 }
702 }
703
704 #[test]
705 fn test_with_pfw_config_override() {
706 let pfw_config = PathFrankWolfeConfig {
707 max_paths: 8,
708 max_probe: 0.5,
709 min_split: 0.1,
710 line_search_evals: 24,
711 };
712 let algo = PathFrankWolfeAlgorithm::new(AlgorithmConfig::default(), pfw_config);
713
714 assert_eq!(algo.pfw_config().max_paths, 8);
715 assert!((algo.pfw_config().max_probe - 0.5).abs() < f64::EPSILON);
716 assert!((algo.pfw_config().min_split - 0.1).abs() < f64::EPSILON);
717 assert_eq!(algo.pfw_config().line_search_evals, 24);
718 }
719
720 #[test]
723 fn test_probe_amount_low_impact() {
724 let total = BigUint::from(1_000_000u64);
728 let algo = PathFrankWolfeAlgorithm::default();
729
730 let result = algo.compute_probe_amount(&total, 0.001, 100_000.0);
731 assert!(result.is_none());
732 }
733
734 #[test]
735 fn test_probe_amount_scaling() {
736 let total = BigUint::from(10_000_000u64);
740 let algo = PathFrankWolfeAlgorithm::default();
741 let gas_cost = 1000.0;
742
743 let probe_high_pi = algo
744 .compute_probe_amount(&total, 0.10, gas_cost)
745 .unwrap();
746 let probe_low_pi = algo
747 .compute_probe_amount(&total, 0.05, gas_cost)
748 .unwrap();
749
750 assert!(probe_high_pi < probe_low_pi);
751
752 let ratio = probe_high_pi.to_f64().unwrap() / probe_low_pi.to_f64().unwrap();
755 assert!(
756 (ratio - 0.5).abs() < 0.01,
757 "expected ratio ~0.5 (inverse proportionality), got {ratio}"
758 );
759 }
760
761 #[test]
762 fn test_probe_amount_within_cap() {
763 let total = BigUint::from(1_000_000u64);
767 let algo = PathFrankWolfeAlgorithm::default();
768
769 let probe_amount = algo
770 .compute_probe_amount(&total, 0.10, 1000.0)
771 .unwrap();
772 assert_eq!(probe_amount, BigUint::from(10_000u64));
773 }
774
775 #[test]
776 fn test_probe_amount_zero_price_impact() {
777 let total = BigUint::from(1_000_000u64);
778 let algo = PathFrankWolfeAlgorithm::default();
779
780 assert!(algo
781 .compute_probe_amount(&total, 0.0, 1000.0)
782 .is_none());
783 }
784
785 #[test]
788 fn test_average_price_impact_redistribution() {
789 let hops = dummy_hops(18, 18);
793 let iter_0 = [PathAllocation {
794 hops: hops.clone(),
795 flow_fraction: 1.0,
796 amount_in: BigUint::from(100_000u64),
797 amount_out: BigUint::from(181_818u64),
798 marginal_price_product: 2.0,
799 }];
800
801 let iter_1 = [
802 PathAllocation {
803 hops: hops.clone(),
804 flow_fraction: 0.5,
805 amount_in: BigUint::from(50_000u64),
806 amount_out: BigUint::from(95_238u64),
807 marginal_price_product: 2.0,
808 },
809 PathAllocation {
810 hops: hops.clone(),
811 flow_fraction: 0.5,
812 amount_in: BigUint::from(50_000u64),
813 amount_out: BigUint::from(95_238u64),
814 marginal_price_product: 2.0,
815 },
816 ];
817
818 let third = 1.0 / 3.0;
819 let iter_2 = [
820 PathAllocation {
821 hops: hops.clone(),
822 flow_fraction: third,
823 amount_in: BigUint::from(33_333u64),
824 amount_out: BigUint::from(64_514u64),
825 marginal_price_product: 2.0,
826 },
827 PathAllocation {
828 hops: hops.clone(),
829 flow_fraction: third,
830 amount_in: BigUint::from(33_333u64),
831 amount_out: BigUint::from(64_514u64),
832 marginal_price_product: 2.0,
833 },
834 PathAllocation {
835 hops: hops.clone(),
836 flow_fraction: third,
837 amount_in: BigUint::from(33_334u64),
838 amount_out: BigUint::from(64_516u64),
839 marginal_price_product: 2.0,
840 },
841 ];
842
843 let pi_0 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_0).unwrap();
844 let pi_1 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_1).unwrap();
845 let pi_2 = PathFrankWolfeAlgorithm::compute_average_price_impact(&iter_2).unwrap();
846
847 assert!(pi_1 < pi_0, "price impact should decrease after first split: {pi_1} >= {pi_0}");
848 assert!(pi_2 < pi_1, "price impact should decrease after second split: {pi_2} >= {pi_1}");
849
850 assert!((pi_0 - 0.09091).abs() < 1e-5, "expected ~0.0909, got {pi_0}");
851 assert!((pi_1 - 0.04762).abs() < 1e-5, "expected ~0.0476, got {pi_1}");
852 assert!((pi_2 - 0.03228).abs() < 1e-5, "expected ~0.0323, got {pi_2}");
853 }
854
855 #[test]
856 fn test_average_price_impact_weighting() {
857 let hops = dummy_hops(18, 18);
863 let allocations = [
864 PathAllocation {
865 hops: hops.clone(),
866 flow_fraction: 0.9,
867 amount_in: BigUint::from(1000u64),
868 amount_out: BigUint::from(900u64),
869 marginal_price_product: 1.0,
870 },
871 PathAllocation {
872 hops: hops.clone(),
873 flow_fraction: 0.1,
874 amount_in: BigUint::from(100u64),
875 amount_out: BigUint::from(50u64),
876 marginal_price_product: 1.0,
877 },
878 ];
879
880 let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
881 assert!((pi - 0.14).abs() < 1e-10, "expected 0.14, got {pi}");
882 }
883
884 #[test]
885 fn test_average_price_impact_mixed_decimals() {
886 let hops = dummy_hops(6, 18);
894 let allocations = [PathAllocation {
895 hops,
896 flow_fraction: 1.0,
897 amount_in: BigUint::from(2_000_000_000u64),
898 amount_out: BigUint::from(900_000_000_000_000_000u64),
899 marginal_price_product: 0.0005,
900 }];
901
902 let pi = PathFrankWolfeAlgorithm::compute_average_price_impact(&allocations).unwrap();
903 assert!((pi - 0.10).abs() < 1e-10, "expected 0.10 for cross-decimal pair, got {pi}");
904 }
905
906 #[tokio::test]
907 async fn test_pi_exit_criterion_with_high_gas() {
908 let token_a = token(0x01, "A");
926 let token_b = token(0x02, "B");
927
928 let cp = |gas: u64| -> Box<dyn ProtocolSim> {
929 Box::new(ConstantProductSim {
930 reserve_0: BigUint::from(5_000u64),
931 reserve_1: BigUint::from(5_000u64),
932 gas,
933 })
934 };
935
936 let (market_hi, gm_hi) = setup_market_unweighted(vec![
938 ("P1", &token_a, &token_b, cp(1_000_000)),
939 ("P2", &token_a, &token_b, cp(1_000_000)),
940 ("P3", &token_a, &token_b, cp(1_000_000)),
941 ]);
942
943 let config = PathFrankWolfeConfig {
944 max_paths: 4,
945 max_probe: 0.25,
946 min_split: 0.01,
947 ..Default::default()
948 };
949 let algo = pfw_algo_with_config(2, config.clone());
950 let derived = derived_with_token_prices(&[&token_a, &token_b]);
951 let ord = order(&token_a, &token_b, 2_000, OrderSide::Sell);
952
953 let result_hi = algo
954 .find_best_route(gm_hi.graph(), market_hi, None, Some(derived.clone()), &ord)
955 .await
956 .unwrap();
957
958 assert_eq!(
959 result_hi.route().swaps().len(),
960 2,
961 "PI exit should stop the loop after the first split"
962 );
963
964 let (market_lo, gm_lo) = setup_market_unweighted(vec![
968 ("P1", &token_a, &token_b, cp(500_000)),
969 ("P2", &token_a, &token_b, cp(500_000)),
970 ("P3", &token_a, &token_b, cp(500_000)),
971 ]);
972
973 let algo_lo = pfw_algo_with_config(2, config);
974 let result_lo = algo_lo
975 .find_best_route(gm_lo.graph(), market_lo, None, Some(derived), &ord)
976 .await
977 .unwrap();
978
979 assert_eq!(
980 result_lo.route().swaps().len(),
981 3,
982 "without PI exit, all three pools should be used"
983 );
984 }
985
986 fn pfw_algo(max_hops: usize) -> PathFrankWolfeAlgorithm {
989 PathFrankWolfeAlgorithm::new(
990 AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(1000), None).unwrap(),
991 PathFrankWolfeConfig::default(),
992 )
993 }
994
995 #[test]
996 fn test_is_duplicate_path_exact_match() {
997 let token_a = token(0x01, "A");
998 let token_b = token(0x02, "B");
999 let candidate =
1000 vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1001 .with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))];
1002 let alloc = PathAllocation {
1003 hops: vec![HopDescriptor::new("P1".to_string(), token_a, token_b)
1004 .with_amounts(BigUint::from(200u64), BigUint::from(50_000u64))],
1005 flow_fraction: 1.0,
1006 amount_in: BigUint::from(100u64),
1007 amount_out: BigUint::from(200u64),
1008 marginal_price_product: 2.0,
1009 };
1010 assert!(PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1011 }
1012
1013 #[test]
1014 fn test_is_duplicate_path_shared_prefix() {
1015 let token_a = token(0x01, "A");
1020 let token_b = token(0x02, "B");
1021 let token_c = token(0x03, "C");
1022
1023 let zero = BigUint::from(0u64);
1024 let alloc = PathAllocation {
1025 hops: vec![
1026 HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1027 .with_amounts(zero.clone(), zero.clone()),
1028 HopDescriptor::new("P2".to_string(), token_b.clone(), token_c.clone())
1029 .with_amounts(zero.clone(), zero.clone()),
1030 ],
1031 flow_fraction: 1.0,
1032 amount_in: BigUint::from(100u64),
1033 amount_out: BigUint::from(200u64),
1034 marginal_price_product: 1.0,
1035 };
1036
1037 let candidate = vec![
1039 HopDescriptor::new("P1".to_string(), token_a, token_b.clone())
1040 .with_amounts(zero.clone(), zero.clone()),
1041 HopDescriptor::new("P3".to_string(), token_b, token_c)
1042 .with_amounts(zero.clone(), zero.clone()),
1043 ];
1044 assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1045 }
1046
1047 #[test]
1048 fn test_is_duplicate_path_same_pool_different_tokens() {
1049 let token_a = token(0x01, "A");
1054 let token_b = token(0x02, "B");
1055 let token_c = token(0x03, "C");
1056 let zero = BigUint::from(0u64);
1057 let alloc = PathAllocation {
1058 hops: vec![HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone())
1059 .with_amounts(zero.clone(), zero.clone())],
1060 flow_fraction: 1.0,
1061 amount_in: BigUint::from(100u64),
1062 amount_out: BigUint::from(200u64),
1063 marginal_price_product: 2.0,
1064 };
1065 let candidate = vec![HopDescriptor::new("P1".to_string(), token_a, token_c)
1066 .with_amounts(zero.clone(), zero.clone())];
1067 assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(&candidate, &[alloc]));
1068 }
1069
1070 #[tokio::test]
1071 async fn test_shared_first_pool_two_outputs() {
1072 let token_a = token(0x01, "A");
1084 let token_b = token(0x02, "B");
1085 let token_c = token(0x03, "C");
1086
1087 let (market, graph_manager) = setup_market_unweighted(vec![
1088 (
1089 "P1",
1090 &token_a,
1091 &token_b,
1092 Box::new(ConstantProductSim {
1093 reserve_0: BigUint::from(10_000u64),
1094 reserve_1: BigUint::from(10_000u64),
1095 gas: 50_000,
1096 }) as Box<dyn ProtocolSim>,
1097 ),
1098 (
1099 "P2",
1100 &token_b,
1101 &token_c,
1102 Box::new(ConstantProductSim {
1103 reserve_0: BigUint::from(1_000u64),
1104 reserve_1: BigUint::from(1_500u64),
1105 gas: 50_000,
1106 }) as Box<dyn ProtocolSim>,
1107 ),
1108 (
1109 "P3",
1110 &token_b,
1111 &token_c,
1112 Box::new(ConstantProductSim {
1113 reserve_0: BigUint::from(1_000u64),
1114 reserve_1: BigUint::from(1_000u64),
1115 gas: 50_000,
1116 }) as Box<dyn ProtocolSim>,
1117 ),
1118 ]);
1119
1120 let algo = pfw_algo(3);
1121 let probe_amount = BigUint::from(1_000u64);
1122 let ord = order(&token_a, &token_c, 1_000, OrderSide::Sell);
1123
1124 let ctx = algo
1125 .inner
1126 .build_context(graph_manager.graph(), market, None, None, &ord)
1127 .await
1128 .unwrap();
1129
1130 let first_path = algo
1132 .find_candidate_path(&ctx, &[], &probe_amount)
1133 .unwrap();
1134 assert_eq!(first_path[0].descriptor.component_id, "P1");
1135 assert_eq!(first_path[1].descriptor.component_id, "P2");
1136
1137 let first_amount_out = first_path[1].amount_out.clone();
1138 let first_alloc = PathAllocation {
1139 hops: first_path,
1140 flow_fraction: 0.5,
1141 amount_in: probe_amount.clone(),
1142 amount_out: first_amount_out,
1143 marginal_price_product: 1.5,
1144 };
1145
1146 let second_path = algo
1148 .find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
1149 .unwrap();
1150 assert_eq!(second_path[0].descriptor.component_id, "P1");
1151 assert_eq!(second_path[1].descriptor.component_id, "P3");
1152
1153 assert!(!PathFrankWolfeAlgorithm::is_duplicate_path(
1155 &second_path,
1156 std::slice::from_ref(&first_alloc)
1157 ));
1158
1159 let second_amount_out = second_path[1].amount_out.clone();
1160 let second_alloc = PathAllocation {
1161 hops: second_path,
1162 flow_fraction: 0.5,
1163 amount_in: probe_amount.clone(),
1164 amount_out: second_amount_out,
1165 marginal_price_product: 1.0,
1166 };
1167
1168 let all_allocs = [first_alloc, second_alloc];
1170 let route = build_split_route(&all_allocs, &ctx.market_data, &ord).unwrap();
1171 let swaps = route.swaps();
1172 assert_eq!(swaps.len(), 3, "expected P1 + P2 + P3 = 3 swaps");
1173 let ids: Vec<&str> = swaps
1174 .iter()
1175 .map(|s| s.component_id())
1176 .collect();
1177 assert_eq!(
1178 ids.iter()
1179 .filter(|&&id| id == "P1")
1180 .count(),
1181 1,
1182 "P1 deduplicated"
1183 );
1184 assert!(ids.contains(&"P2"));
1185 assert!(ids.contains(&"P3"));
1186 assert_eq!(route.total_gas(), BigUint::from(150_000u64));
1188 }
1189
1190 #[tokio::test]
1191 async fn test_duplicate_path_stops_iteration() {
1192 let token_a = token(0x01, "A");
1195 let token_b = token(0x02, "B");
1196
1197 let (market, graph_manager) = setup_market_unweighted(vec![(
1198 "P1",
1199 &token_a,
1200 &token_b,
1201 Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
1202 )]);
1203
1204 let algo = pfw_algo(2);
1205 let probe_amount = BigUint::from(100u64);
1206 let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1207
1208 let ctx = algo
1209 .inner
1210 .build_context(graph_manager.graph(), market, None, None, &ord)
1211 .await
1212 .unwrap();
1213
1214 let first_path = algo
1215 .find_candidate_path(&ctx, &[], &probe_amount)
1216 .unwrap();
1217 assert_eq!(first_path[0].descriptor.component_id, "P1");
1218
1219 let first_alloc = PathAllocation {
1220 hops: first_path,
1221 flow_fraction: 1.0,
1222 amount_in: probe_amount.clone(),
1223 amount_out: BigUint::from(200u64),
1224 marginal_price_product: 2.0,
1225 };
1226
1227 let second_path = algo
1229 .find_candidate_path(&ctx, std::slice::from_ref(&first_alloc), &probe_amount)
1230 .unwrap();
1231 assert!(PathFrankWolfeAlgorithm::is_duplicate_path(
1232 &second_path,
1233 std::slice::from_ref(&first_alloc)
1234 ));
1235 }
1236
1237 #[test]
1238 fn test_with_zero_gas_zeroes_gas_keeps_amounts() {
1239 let token_a = token(0x01, "A");
1240 let token_b = token(0x02, "B");
1241 let sim = MockProtocolSim::new(2.0).with_gas(50_000);
1242
1243 let overrides = MarketOverrides::empty()
1244 .with_override("P1".to_string(), Box::new(sim.clone()))
1245 .with_zero_gas("P1".to_string(), token_a.address.clone(), token_b.address.clone());
1246
1247 let result = overrides
1248 .get(&"P1".to_string())
1249 .unwrap()
1250 .get_amount_out(BigUint::from(100u64), &token_a, &token_b)
1251 .unwrap();
1252
1253 assert_eq!(result.amount, BigUint::from(200u64), "amount unaffected");
1254 assert_eq!(result.gas, BigUint::ZERO, "gas zeroed by with_zero_gas");
1255 }
1256
1257 fn pfw_algo_with_config(
1260 max_hops: usize,
1261 pfw_config: PathFrankWolfeConfig,
1262 ) -> PathFrankWolfeAlgorithm {
1263 PathFrankWolfeAlgorithm::new(
1264 AlgorithmConfig::new(1, max_hops, StdDuration::from_millis(5000), None).unwrap(),
1265 pfw_config,
1266 )
1267 }
1268
1269 #[tokio::test]
1270 async fn test_single_path_no_split() {
1271 let token_a = token(0x01, "A");
1274 let token_b = token(0x02, "B");
1275
1276 let (market, graph_manager) = setup_market_unweighted(vec![(
1277 "P1",
1278 &token_a,
1279 &token_b,
1280 Box::new(ConstantProductSim {
1281 reserve_0: BigUint::from(10_000u64),
1282 reserve_1: BigUint::from(10_000u64),
1283 gas: 50_000,
1284 }) as Box<dyn ProtocolSim>,
1285 )]);
1286
1287 let algo = pfw_algo(2);
1288 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1289 let ord = order(&token_a, &token_b, 1_000, OrderSide::Sell);
1290
1291 let result = algo
1292 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1293 .await
1294 .unwrap();
1295
1296 let swaps = result.route().swaps();
1297 assert_eq!(swaps.len(), 1, "single path, single swap");
1298 assert_eq!(swaps[0].component_id(), "P1");
1299 }
1300
1301 #[tokio::test]
1302 async fn test_two_parallel_pools_symmetric() {
1303 let token_a = token(0x01, "A");
1309 let token_b = token(0x02, "B");
1310
1311 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1312 Box::new(ConstantProductSim {
1313 reserve_0: BigUint::from(reserve),
1314 reserve_1: BigUint::from(reserve),
1315 gas: 50_000,
1316 })
1317 };
1318
1319 let (market, graph_manager) = setup_market_unweighted(vec![
1320 ("P1", &token_a, &token_b, cp(100_000)),
1321 ("P2", &token_a, &token_b, cp(100_000)),
1322 ]);
1323
1324 let algo = pfw_algo_with_config(
1325 2,
1326 PathFrankWolfeConfig {
1327 max_paths: 4,
1328 max_probe: 0.25,
1329 min_split: 0.01,
1330 ..Default::default()
1331 },
1332 );
1333 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1334 let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1335
1336 let result = algo
1337 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1338 .await
1339 .unwrap();
1340
1341 let swaps = result.route().swaps();
1342 assert_eq!(swaps.len(), 2, "should use both pools");
1343 let ids: Vec<&str> = swaps
1344 .iter()
1345 .map(|s| s.component_id())
1346 .collect();
1347 assert!(ids.contains(&"P1"));
1348 assert!(ids.contains(&"P2"));
1349
1350 let amounts: Vec<f64> = swaps
1352 .iter()
1353 .map(|s| s.amount_in().to_f64().unwrap())
1354 .collect();
1355 let ratio = amounts[0] / amounts[1];
1356 assert!(
1357 (0.8..=1.2).contains(&ratio),
1358 "expected roughly equal split, got ratio {ratio} (amounts: {amounts:?})"
1359 );
1360 }
1361
1362 #[tokio::test]
1363 async fn test_two_parallel_pools_asymmetric() {
1364 let token_a = token(0x01, "A");
1370 let token_b = token(0x02, "B");
1371
1372 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1373 Box::new(ConstantProductSim {
1374 reserve_0: BigUint::from(reserve),
1375 reserve_1: BigUint::from(reserve),
1376 gas: 50_000,
1377 })
1378 };
1379
1380 let (market, graph_manager) = setup_market_unweighted(vec![
1381 ("deep", &token_a, &token_b, cp(200_000)),
1382 ("shallow", &token_a, &token_b, cp(50_000)),
1383 ]);
1384
1385 let algo = pfw_algo_with_config(
1386 2,
1387 PathFrankWolfeConfig {
1388 max_paths: 4,
1389 max_probe: 0.5,
1390 min_split: 0.01,
1391 ..Default::default()
1392 },
1393 );
1394 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1395 let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1396
1397 let result = algo
1398 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1399 .await
1400 .unwrap();
1401
1402 let swaps = result.route().swaps();
1403 assert_eq!(swaps.len(), 2, "should use both pools");
1404
1405 let deep_swap = swaps
1406 .iter()
1407 .find(|s| s.component_id() == "deep")
1408 .unwrap();
1409 let shallow_swap = swaps
1410 .iter()
1411 .find(|s| s.component_id() == "shallow")
1412 .unwrap();
1413
1414 assert!(
1415 deep_swap.amount_in() > shallow_swap.amount_in(),
1416 "deep pool should get more flow: deep={}, shallow={}",
1417 deep_swap.amount_in(),
1418 shallow_swap.amount_in()
1419 );
1420 }
1421
1422 #[tokio::test]
1423 async fn test_split_vs_single_route() {
1424 let token_a = token(0x01, "A");
1431 let token_b = token(0x02, "B");
1432
1433 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1434 Box::new(ConstantProductSim {
1435 reserve_0: BigUint::from(reserve),
1436 reserve_1: BigUint::from(reserve),
1437 gas: 50_000,
1438 })
1439 };
1440
1441 let (market, graph_manager) = setup_market_unweighted(vec![
1442 ("P1", &token_a, &token_b, cp(100_000)),
1443 ("P2", &token_a, &token_b, cp(100_000)),
1444 ]);
1445
1446 let algo = pfw_algo_with_config(
1447 2,
1448 PathFrankWolfeConfig {
1449 max_paths: 4,
1450 max_probe: 0.25,
1451 min_split: 0.01,
1452 ..Default::default()
1453 },
1454 );
1455 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1457 let ord = order(&token_a, &token_b, 50_000, OrderSide::Sell);
1458
1459 let split_result = algo
1460 .find_best_route(
1461 graph_manager.graph(),
1462 market.clone(),
1463 None,
1464 Some(derived.clone()),
1465 &ord,
1466 )
1467 .await
1468 .unwrap();
1469
1470 let single_algo =
1472 pfw_algo_with_config(2, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1473 let single_result = single_algo
1474 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1475 .await
1476 .unwrap();
1477
1478 assert!(
1479 split_result.net_amount_out() > single_result.net_amount_out(),
1480 "split output ({}) should beat single ({})",
1481 split_result.net_amount_out(),
1482 single_result.net_amount_out()
1483 );
1484 }
1485
1486 #[tokio::test]
1487 async fn test_three_paths_discovered() {
1488 let token_a = token(0x01, "A");
1495 let token_b = token(0x02, "B");
1496
1497 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1498 Box::new(ConstantProductSim {
1499 reserve_0: BigUint::from(reserve),
1500 reserve_1: BigUint::from(reserve),
1501 gas: 50_000,
1502 })
1503 };
1504
1505 let (market, graph_manager) = setup_market_unweighted(vec![
1506 ("P1", &token_a, &token_b, cp(100_000)),
1507 ("P2", &token_a, &token_b, cp(80_000)),
1508 ("P3", &token_a, &token_b, cp(60_000)),
1509 ]);
1510
1511 let algo = pfw_algo_with_config(
1512 2,
1513 PathFrankWolfeConfig {
1514 max_paths: 5,
1515 max_probe: 0.5,
1516 min_split: 0.01,
1517 line_search_evals: 16,
1518 },
1519 );
1520 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1521 let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1522
1523 let result = algo
1524 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1525 .await
1526 .unwrap();
1527
1528 let swaps = result.route().swaps();
1529 let ids: Vec<&str> = swaps
1530 .iter()
1531 .map(|s| s.component_id())
1532 .collect();
1533 assert_eq!(ids.len(), 3, "expected 3 paths, got {ids:?}");
1534 assert!(ids.contains(&"P1"), "missing P1");
1535 assert!(ids.contains(&"P2"), "missing P2");
1536 assert!(ids.contains(&"P3"), "missing P3");
1537 }
1538
1539 #[tokio::test]
1540 async fn test_shared_pool_degradation() {
1541 let token_a = token(0x01, "A");
1551 let token_b = token(0x02, "B");
1552 let token_c = token(0x03, "C");
1553
1554 let (market, graph_manager) = setup_market_unweighted(vec![
1555 (
1556 "P1",
1557 &token_a,
1558 &token_b,
1559 Box::new(ConstantProductSim {
1560 reserve_0: BigUint::from(100_000u64),
1561 reserve_1: BigUint::from(100_000u64),
1562 gas: 50_000,
1563 }) as Box<dyn ProtocolSim>,
1564 ),
1565 (
1566 "P2",
1567 &token_a,
1568 &token_b,
1569 Box::new(ConstantProductSim {
1570 reserve_0: BigUint::from(100_000u64),
1571 reserve_1: BigUint::from(100_000u64),
1572 gas: 50_000,
1573 }) as Box<dyn ProtocolSim>,
1574 ),
1575 (
1576 "P_shared",
1577 &token_b,
1578 &token_c,
1579 Box::new(ConstantProductSim {
1580 reserve_0: BigUint::from(200_000u64),
1581 reserve_1: BigUint::from(200_000u64),
1582 gas: 50_000,
1583 }) as Box<dyn ProtocolSim>,
1584 ),
1585 ]);
1586
1587 let algo = pfw_algo_with_config(
1588 3,
1589 PathFrankWolfeConfig {
1590 max_paths: 4,
1591 max_probe: 0.5,
1592 min_split: 0.01,
1593 ..Default::default()
1594 },
1595 );
1596 let derived = derived_with_token_prices(&[&token_a, &token_b, &token_c]);
1597 let ord = order(&token_a, &token_c, 20_000, OrderSide::Sell);
1598
1599 let result = algo
1600 .find_best_route(
1601 graph_manager.graph(),
1602 market.clone(),
1603 None,
1604 Some(derived.clone()),
1605 &ord,
1606 )
1607 .await
1608 .unwrap();
1609
1610 let swaps = result.route().swaps();
1611 let ids: Vec<&str> = swaps
1612 .iter()
1613 .map(|s| s.component_id())
1614 .collect();
1615
1616 assert!(ids.contains(&"P1") && ids.contains(&"P2"), "should use both entry pools");
1618 assert!(ids.contains(&"P_shared"), "must use shared B→C pool");
1619
1620 let single_algo =
1623 pfw_algo_with_config(3, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1624 let single_result = single_algo
1625 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1626 .await
1627 .unwrap();
1628
1629 assert!(
1630 result.net_amount_out() > single_result.net_amount_out(),
1631 "split ({}) should be > single ({})",
1632 result.net_amount_out(),
1633 single_result.net_amount_out()
1634 );
1635 }
1636
1637 #[tokio::test]
1638 async fn test_timeout_mid_iteration() {
1639 let token_a = token(0x01, "A");
1649 let token_b = token(0x02, "B");
1650
1651 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1652 Box::new(ConstantProductSim {
1653 reserve_0: BigUint::from(reserve),
1654 reserve_1: BigUint::from(reserve),
1655 gas: 50_000,
1656 })
1657 };
1658
1659 let pool_names: [&str; 8] = ["P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7"];
1660
1661 let pfw_config =
1662 PathFrankWolfeConfig { max_paths: 8, min_split: 0.001, ..Default::default() };
1663 let ord = order(&token_a, &token_b, 80_000, OrderSide::Sell);
1664
1665 let pools: Vec<_> = pool_names
1667 .iter()
1668 .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1669 .collect();
1670 let (market, graph_manager) = setup_market_unweighted(pools);
1671 let generous_algo = pfw_algo_with_config(2, pfw_config.clone());
1672 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1673 let generous_result = generous_algo
1674 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1675 .await
1676 .unwrap();
1677 let generous_swaps = generous_result.route().swaps().len();
1678
1679 let pools: Vec<_> = pool_names
1681 .iter()
1682 .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1683 .collect();
1684 let (market, graph_manager) = setup_market_unweighted(pools);
1685 let timeout_algo = PathFrankWolfeAlgorithm::new(
1686 AlgorithmConfig::new(1, 2, StdDuration::from_millis(1), None).unwrap(),
1687 pfw_config,
1688 );
1689 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1690 let timeout_result = timeout_algo
1691 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1692 .await
1693 .unwrap();
1694 let timeout_swaps = timeout_result.route().swaps().len();
1695
1696 assert!(
1697 !timeout_result
1698 .route()
1699 .swaps()
1700 .is_empty(),
1701 "timed-out result must still contain at least one swap"
1702 );
1703 assert!(
1704 timeout_swaps < generous_swaps,
1705 "timed-out result ({timeout_swaps} swaps) should use fewer paths \
1706 than generous result ({generous_swaps} swaps)"
1707 );
1708 }
1709}