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, NoPathReason,
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::default();
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 components 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 = |component: &str| {
1112 HopDescriptor::new(component.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 component 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_component_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_component_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_amount_too_small_surfaces_from_inner_bf() {
1423 let token_a = token(0x01, "A");
1427 let token_b = token(0x02, "B");
1428
1429 let (market, graph_manager) = setup_market_unweighted(vec![(
1430 "component_ab",
1431 &token_a,
1432 &token_b,
1433 Box::new(MockProtocolSim::new(0.5)) as Box<dyn ProtocolSim>,
1434 )]);
1435
1436 let algo = pfw_algo(3);
1437 let ord = order(&token_a, &token_b, 1, OrderSide::Sell);
1438
1439 let result = algo
1440 .find_best_route(graph_manager.graph(), market, None, None, &ord)
1441 .await;
1442
1443 assert!(matches!(
1444 result,
1445 Err(AlgorithmError::NoPath { reason: NoPathReason::AmountTooSmall, .. })
1446 ));
1447 }
1448
1449 #[tokio::test]
1450 async fn test_single_path_no_split() {
1451 let token_a = token(0x01, "A");
1454 let token_b = token(0x02, "B");
1455
1456 let (market, graph_manager) = setup_market_unweighted(vec![(
1457 "P1",
1458 &token_a,
1459 &token_b,
1460 Box::new(ConstantProductSim {
1461 reserve_0: BigUint::from(10_000u64),
1462 reserve_1: BigUint::from(10_000u64),
1463 gas: 50_000,
1464 }) as Box<dyn ProtocolSim>,
1465 )]);
1466
1467 let algo = pfw_algo(2);
1468 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1469 let ord = order(&token_a, &token_b, 1_000, OrderSide::Sell);
1470
1471 let result = algo
1472 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1473 .await
1474 .unwrap();
1475
1476 let swaps = result.route().swaps();
1477 assert_eq!(swaps.len(), 1, "single path, single swap");
1478 assert_eq!(swaps[0].component_id(), "P1");
1479 }
1480
1481 #[tokio::test]
1482 async fn test_two_parallel_components_symmetric() {
1483 let token_a = token(0x01, "A");
1489 let token_b = token(0x02, "B");
1490
1491 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1492 Box::new(ConstantProductSim {
1493 reserve_0: BigUint::from(reserve),
1494 reserve_1: BigUint::from(reserve),
1495 gas: 50_000,
1496 })
1497 };
1498
1499 let (market, graph_manager) = setup_market_unweighted(vec![
1500 ("P1", &token_a, &token_b, cp(100_000)),
1501 ("P2", &token_a, &token_b, cp(100_000)),
1502 ]);
1503
1504 let algo = pfw_algo_with_config(
1505 2,
1506 PathFrankWolfeConfig {
1507 max_paths: 4,
1508 max_probe: 0.25,
1509 min_split: 0.01,
1510 ..Default::default()
1511 },
1512 );
1513 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1514 let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1515
1516 let result = algo
1517 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1518 .await
1519 .unwrap();
1520
1521 let swaps = result.route().swaps();
1522 assert_eq!(swaps.len(), 2, "should use both components");
1523 let ids: Vec<&str> = swaps
1524 .iter()
1525 .map(|s| s.component_id())
1526 .collect();
1527 assert!(ids.contains(&"P1"));
1528 assert!(ids.contains(&"P2"));
1529
1530 let amounts: Vec<f64> = swaps
1532 .iter()
1533 .map(|s| s.amount_in().to_f64().unwrap())
1534 .collect();
1535 let ratio = amounts[0] / amounts[1];
1536 assert!(
1537 (0.8..=1.2).contains(&ratio),
1538 "expected roughly equal split, got ratio {ratio} (amounts: {amounts:?})"
1539 );
1540 }
1541
1542 #[tokio::test]
1543 async fn find_best_route_reports_price_impact_for_split() {
1544 let token_a = token(0x01, "A");
1548 let token_b = token(0x02, "B");
1549
1550 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1551 Box::new(ConstantProductSim {
1552 reserve_0: BigUint::from(reserve),
1553 reserve_1: BigUint::from(reserve),
1554 gas: 50_000,
1555 })
1556 };
1557
1558 let (market, graph_manager) = setup_market_unweighted(vec![
1559 ("P1", &token_a, &token_b, cp(100_000)),
1560 ("P2", &token_a, &token_b, cp(100_000)),
1561 ]);
1562
1563 let algo = pfw_algo_with_config(
1564 2,
1565 PathFrankWolfeConfig {
1566 max_paths: 4,
1567 max_probe: 0.25,
1568 min_split: 0.01,
1569 ..Default::default()
1570 },
1571 );
1572 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1573 let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1574
1575 let result = algo
1576 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1577 .await
1578 .unwrap();
1579
1580 assert_eq!(result.route().swaps().len(), 2, "expected a split route");
1581 assert!(result.price_impact().is_some(), "split route should report price impact");
1582 }
1583
1584 #[tokio::test]
1585 async fn test_two_parallel_components_asymmetric() {
1586 let token_a = token(0x01, "A");
1592 let token_b = token(0x02, "B");
1593
1594 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1595 Box::new(ConstantProductSim {
1596 reserve_0: BigUint::from(reserve),
1597 reserve_1: BigUint::from(reserve),
1598 gas: 50_000,
1599 })
1600 };
1601
1602 let (market, graph_manager) = setup_market_unweighted(vec![
1603 ("deep", &token_a, &token_b, cp(200_000)),
1604 ("shallow", &token_a, &token_b, cp(50_000)),
1605 ]);
1606
1607 let algo = pfw_algo_with_config(
1608 2,
1609 PathFrankWolfeConfig {
1610 max_paths: 4,
1611 max_probe: 0.5,
1612 min_split: 0.01,
1613 ..Default::default()
1614 },
1615 );
1616 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1617 let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1618
1619 let result = algo
1620 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1621 .await
1622 .unwrap();
1623
1624 let swaps = result.route().swaps();
1625 assert_eq!(swaps.len(), 2, "should use both components");
1626
1627 let deep_swap = swaps
1628 .iter()
1629 .find(|s| s.component_id() == "deep")
1630 .unwrap();
1631 let shallow_swap = swaps
1632 .iter()
1633 .find(|s| s.component_id() == "shallow")
1634 .unwrap();
1635
1636 assert!(
1637 deep_swap.amount_in() > shallow_swap.amount_in(),
1638 "deep component should get more flow: deep={}, shallow={}",
1639 deep_swap.amount_in(),
1640 shallow_swap.amount_in()
1641 );
1642 }
1643
1644 #[tokio::test]
1645 async fn test_split_vs_single_route() {
1646 let token_a = token(0x01, "A");
1653 let token_b = token(0x02, "B");
1654
1655 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1656 Box::new(ConstantProductSim {
1657 reserve_0: BigUint::from(reserve),
1658 reserve_1: BigUint::from(reserve),
1659 gas: 50_000,
1660 })
1661 };
1662
1663 let (market, graph_manager) = setup_market_unweighted(vec![
1664 ("P1", &token_a, &token_b, cp(100_000)),
1665 ("P2", &token_a, &token_b, cp(100_000)),
1666 ]);
1667
1668 let algo = pfw_algo_with_config(
1669 2,
1670 PathFrankWolfeConfig {
1671 max_paths: 4,
1672 max_probe: 0.25,
1673 min_split: 0.01,
1674 ..Default::default()
1675 },
1676 );
1677 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1679 let ord = order(&token_a, &token_b, 50_000, OrderSide::Sell);
1680
1681 let split_result = algo
1682 .find_best_route(
1683 graph_manager.graph(),
1684 market.clone(),
1685 None,
1686 Some(derived.clone()),
1687 &ord,
1688 )
1689 .await
1690 .unwrap();
1691
1692 let single_algo =
1694 pfw_algo_with_config(2, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1695 let single_result = single_algo
1696 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1697 .await
1698 .unwrap();
1699
1700 assert!(
1701 split_result.net_amount_out() > single_result.net_amount_out(),
1702 "split output ({}) should beat single ({})",
1703 split_result.net_amount_out(),
1704 single_result.net_amount_out()
1705 );
1706 }
1707
1708 #[tokio::test]
1709 async fn test_three_paths_discovered() {
1710 let token_a = token(0x01, "A");
1717 let token_b = token(0x02, "B");
1718
1719 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1720 Box::new(ConstantProductSim {
1721 reserve_0: BigUint::from(reserve),
1722 reserve_1: BigUint::from(reserve),
1723 gas: 50_000,
1724 })
1725 };
1726
1727 let (market, graph_manager) = setup_market_unweighted(vec![
1728 ("P1", &token_a, &token_b, cp(100_000)),
1729 ("P2", &token_a, &token_b, cp(80_000)),
1730 ("P3", &token_a, &token_b, cp(60_000)),
1731 ]);
1732
1733 let algo = pfw_algo_with_config(
1734 2,
1735 PathFrankWolfeConfig {
1736 max_paths: 5,
1737 max_probe: 0.5,
1738 min_split: 0.01,
1739 line_search_evals: 16,
1740 },
1741 );
1742 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1743 let ord = order(&token_a, &token_b, 30_000, OrderSide::Sell);
1744
1745 let result = algo
1746 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1747 .await
1748 .unwrap();
1749
1750 let swaps = result.route().swaps();
1751 let ids: Vec<&str> = swaps
1752 .iter()
1753 .map(|s| s.component_id())
1754 .collect();
1755 assert_eq!(ids.len(), 3, "expected 3 paths, got {ids:?}");
1756 assert!(ids.contains(&"P1"), "missing P1");
1757 assert!(ids.contains(&"P2"), "missing P2");
1758 assert!(ids.contains(&"P3"), "missing P3");
1759 }
1760
1761 #[tokio::test]
1762 async fn test_shared_component_degradation() {
1763 let token_a = token(0x01, "A");
1773 let token_b = token(0x02, "B");
1774 let token_c = token(0x03, "C");
1775
1776 let (market, graph_manager) = setup_market_unweighted(vec![
1777 (
1778 "P1",
1779 &token_a,
1780 &token_b,
1781 Box::new(ConstantProductSim {
1782 reserve_0: BigUint::from(100_000u64),
1783 reserve_1: BigUint::from(100_000u64),
1784 gas: 50_000,
1785 }) as Box<dyn ProtocolSim>,
1786 ),
1787 (
1788 "P2",
1789 &token_a,
1790 &token_b,
1791 Box::new(ConstantProductSim {
1792 reserve_0: BigUint::from(100_000u64),
1793 reserve_1: BigUint::from(100_000u64),
1794 gas: 50_000,
1795 }) as Box<dyn ProtocolSim>,
1796 ),
1797 (
1798 "P_shared",
1799 &token_b,
1800 &token_c,
1801 Box::new(ConstantProductSim {
1802 reserve_0: BigUint::from(200_000u64),
1803 reserve_1: BigUint::from(200_000u64),
1804 gas: 50_000,
1805 }) as Box<dyn ProtocolSim>,
1806 ),
1807 ]);
1808
1809 let algo = pfw_algo_with_config(
1810 3,
1811 PathFrankWolfeConfig {
1812 max_paths: 4,
1813 max_probe: 0.5,
1814 min_split: 0.01,
1815 ..Default::default()
1816 },
1817 );
1818 let derived = derived_with_token_prices(&[&token_a, &token_b, &token_c]);
1819 let ord = order(&token_a, &token_c, 20_000, OrderSide::Sell);
1820
1821 let result = algo
1822 .find_best_route(
1823 graph_manager.graph(),
1824 market.clone(),
1825 None,
1826 Some(derived.clone()),
1827 &ord,
1828 )
1829 .await
1830 .unwrap();
1831
1832 let swaps = result.route().swaps();
1833 let ids: Vec<&str> = swaps
1834 .iter()
1835 .map(|s| s.component_id())
1836 .collect();
1837
1838 assert!(ids.contains(&"P1") && ids.contains(&"P2"), "should use both entry components");
1840 assert!(ids.contains(&"P_shared"), "must use shared B→C component");
1841
1842 let single_algo =
1845 pfw_algo_with_config(3, PathFrankWolfeConfig { max_paths: 1, ..Default::default() });
1846 let single_result = single_algo
1847 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1848 .await
1849 .unwrap();
1850
1851 assert!(
1852 result.net_amount_out() > single_result.net_amount_out(),
1853 "split ({}) should be > single ({})",
1854 result.net_amount_out(),
1855 single_result.net_amount_out()
1856 );
1857 }
1858
1859 #[tokio::test]
1860 async fn test_timeout_mid_iteration() {
1861 let token_a = token(0x01, "A");
1871 let token_b = token(0x02, "B");
1872
1873 let cp = |reserve: u64| -> Box<dyn ProtocolSim> {
1874 Box::new(ConstantProductSim {
1875 reserve_0: BigUint::from(reserve),
1876 reserve_1: BigUint::from(reserve),
1877 gas: 50_000,
1878 })
1879 };
1880
1881 let component_names: [&str; 8] = ["P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7"];
1882
1883 let pfw_config =
1884 PathFrankWolfeConfig { max_paths: 8, min_split: 0.001, ..Default::default() };
1885 let ord = order(&token_a, &token_b, 80_000, OrderSide::Sell);
1886
1887 let components: Vec<_> = component_names
1889 .iter()
1890 .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1891 .collect();
1892 let (market, graph_manager) = setup_market_unweighted(components);
1893 let generous_algo = pfw_algo_with_config(2, pfw_config.clone());
1894 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1895 let generous_result = generous_algo
1896 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1897 .await
1898 .unwrap();
1899 let generous_swaps = generous_result.route().swaps().len();
1900
1901 let components: Vec<_> = component_names
1903 .iter()
1904 .map(|id| (*id, &token_a, &token_b, cp(100_000)))
1905 .collect();
1906 let (market, graph_manager) = setup_market_unweighted(components);
1907 let timeout_algo = PathFrankWolfeAlgorithm::new(
1908 AlgorithmConfig::new(1, 2, StdDuration::from_millis(1), None).unwrap(),
1909 pfw_config,
1910 );
1911 let derived = derived_with_token_prices(&[&token_a, &token_b]);
1912 let timeout_result = timeout_algo
1913 .find_best_route(graph_manager.graph(), market, None, Some(derived), &ord)
1914 .await
1915 .unwrap();
1916 let timeout_swaps = timeout_result.route().swaps().len();
1917
1918 assert!(
1919 !timeout_result
1920 .route()
1921 .swaps()
1922 .is_empty(),
1923 "timed-out result must still contain at least one swap"
1924 );
1925 assert!(
1926 timeout_swaps < generous_swaps,
1927 "timed-out result ({timeout_swaps} swaps) should use fewer paths \
1928 than generous result ({generous_swaps} swaps)"
1929 );
1930 }
1931}