Skip to main content

fynd_core/algorithm/
most_liquid.rs

1//! Most Liquid algorithm implementation.
2//!
3//! This algorithm finds routes by:
4//! 1. Finding all edge paths up to max_hops using BFS (shorter paths first, all parallel edges)
5//! 2. Scoring and sorting paths by spot price, fees, and liquidity depth
6//! 3. Simulating paths with actual ProtocolSim to get accurate output (best paths first)
7//! 4. Ranking by net output (output - gas cost in output token terms)
8//! 5. Returning the best route with stats recorded to the tracing span
9
10use std::{
11    collections::{HashMap, HashSet, VecDeque},
12    time::{Duration, Instant},
13};
14
15use metrics::{counter, histogram};
16use num_bigint::{BigInt, BigUint};
17use num_traits::ToPrimitive;
18use petgraph::prelude::EdgeRef;
19use tracing::{debug, instrument, trace};
20use tycho_simulation::{
21    tycho_common::simulation::protocol_sim::ProtocolSim,
22    tycho_core::models::{token::Token, Address},
23};
24
25use super::{Algorithm, AlgorithmConfig, NoPathReason};
26use crate::{
27    derived::{computation::ComputationRequirements, types::TokenGasPrices, SharedDerivedDataRef},
28    feed::market_data::{SharedMarketData, SharedMarketDataRef},
29    graph::{petgraph::StableDiGraph, Path, PetgraphStableDiGraphManager},
30    types::{ComponentId, Order, Route, RouteResult, Swap},
31    AlgorithmError,
32};
33/// Algorithm that selects routes based on expected output after gas.
34pub struct MostLiquidAlgorithm {
35    min_hops: usize,
36    max_hops: usize,
37    timeout: Duration,
38    max_routes: Option<usize>,
39}
40
41/// Algorithm-specific edge data for liquidity-based routing.
42///
43/// Used by the MostLiquid algorithm to score paths based on expected output.
44/// Contains the spot price and liquidity depth.
45/// Note that the fee is included in the spot price already.
46#[derive(Debug, Clone, Default)]
47pub struct DepthAndPrice {
48    /// Spot price (token_out per token_in) for this edge direction.
49    pub spot_price: f64,
50    /// Liquidity depth in USD (or native token terms).
51    pub depth: f64,
52}
53
54impl DepthAndPrice {
55    /// Creates a new DepthAndPrice with all fields set.
56    #[cfg(test)]
57    pub fn new(spot_price: f64, depth: f64) -> Self {
58        Self { spot_price, depth }
59    }
60
61    #[cfg(test)]
62    pub fn from_protocol_sim(
63        sim: &impl ProtocolSim,
64        token_in: &Token,
65        token_out: &Token,
66    ) -> Result<Self, AlgorithmError> {
67        Ok(Self {
68            spot_price: sim
69                .spot_price(token_in, token_out)
70                .map_err(|e| {
71                    AlgorithmError::Other(format!("missing spot price for DepthAndPrice: {:?}", e))
72                })?,
73            depth: sim
74                .get_limits(token_in.address.clone(), token_out.address.clone())
75                .map_err(|e| {
76                    AlgorithmError::Other(format!("missing depth for DepthAndPrice: {:?}", e))
77                })?
78                .0
79                .to_f64()
80                .ok_or_else(|| {
81                    AlgorithmError::Other("depth conversion to f64 failed".to_string())
82                })?,
83        })
84    }
85}
86
87impl crate::graph::EdgeWeightFromSimAndDerived for DepthAndPrice {
88    fn from_sim_and_derived(
89        _sim: &dyn ProtocolSim,
90        component_id: &ComponentId,
91        token_in: &Token,
92        token_out: &Token,
93        derived: &crate::derived::DerivedData,
94    ) -> Option<Self> {
95        let key = (component_id.clone(), token_in.address.clone(), token_out.address.clone());
96
97        // Use pre-computed spot price
98        let spot_price = derived
99            .spot_prices()
100            .and_then(|prices| prices.get(&key).copied())?;
101
102        // Look up pre-computed depth
103        let depth = derived
104            .pool_depths()
105            .and_then(|depths| depths.get(&key))?
106            .to_f64()?;
107
108        Some(Self { spot_price, depth })
109    }
110}
111
112impl MostLiquidAlgorithm {
113    /// Creates a new MostLiquidAlgorithm with default settings.
114    pub fn new() -> Self {
115        Self { min_hops: 1, max_hops: 3, timeout: Duration::from_millis(500), max_routes: None }
116    }
117
118    /// Creates a new MostLiquidAlgorithm with custom settings.
119    pub fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
120        Ok(Self {
121            min_hops: config.min_hops(),
122            max_hops: config.max_hops(),
123            timeout: config.timeout(),
124            max_routes: config.max_routes(),
125        })
126    }
127
128    /// Finds all paths between two tokens using BFS directly on the graph.
129    ///
130    /// This is a helper method that operates on the graph without needing the graph manager.
131    /// It performs BFS traversal to find all paths within the hop budget.
132    ///
133    /// # Errors
134    ///
135    /// Returns `AlgorithmError` if:
136    /// - Source token is not in the graph
137    /// - Destination token is not in the graph
138    #[instrument(level = "debug", skip(graph))]
139    fn find_paths<'a>(
140        graph: &'a StableDiGraph<DepthAndPrice>,
141        from: &Address,
142        to: &Address,
143        min_hops: usize,
144        max_hops: usize,
145    ) -> Result<Vec<Path<'a, DepthAndPrice>>, AlgorithmError> {
146        if min_hops == 0 || min_hops > max_hops {
147            return Err(AlgorithmError::InvalidConfiguration {
148                reason: format!(
149                    "invalid hop configuration: min_hops={min_hops} max_hops={max_hops}",
150                ),
151            });
152        }
153
154        // Find source and destination nodes by address
155        // TODO: this could be optimized by using a node index map in the graph manager
156        let from_idx = graph
157            .node_indices()
158            .find(|&n| &graph[n] == from)
159            .ok_or(AlgorithmError::NoPath {
160                from: from.clone(),
161                to: to.clone(),
162                reason: NoPathReason::SourceTokenNotInGraph,
163            })?;
164        let to_idx = graph
165            .node_indices()
166            .find(|&n| &graph[n] == to)
167            .ok_or(AlgorithmError::NoPath {
168                from: from.clone(),
169                to: to.clone(),
170                reason: NoPathReason::DestinationTokenNotInGraph,
171            })?;
172
173        let mut paths = Vec::new();
174        let mut queue = VecDeque::new();
175        queue.push_back((from_idx, Path::new()));
176
177        while let Some((current_node, current_path)) = queue.pop_front() {
178            if current_path.len() >= max_hops {
179                continue;
180            }
181
182            for edge in graph.edges(current_node) {
183                let next_node = edge.target();
184                let next_addr = &graph[next_node];
185
186                // Skip paths that revisit a token already in the path.
187                // Exception: when source == destination, the destination may appear at the end
188                // (forming a first == last cycle, e.g. USDC → WETH → USDC). All other intermediate
189                // cycles (e.g. USDC → WETH → WBTC → WETH) are not supported by Tycho execution.
190                let already_visited = current_path.tokens.contains(&next_addr);
191                let is_closing_circular_route = from_idx == to_idx && next_node == to_idx;
192                if already_visited && !is_closing_circular_route {
193                    continue;
194                }
195
196                let mut new_path = current_path.clone();
197                new_path.add_hop(&graph[current_node], edge.weight(), next_addr);
198
199                if next_node == to_idx && new_path.len() >= min_hops {
200                    paths.push(new_path.clone());
201                }
202
203                queue.push_back((next_node, new_path));
204            }
205        }
206
207        Ok(paths)
208    }
209
210    /// Attempts to score a path based on spot prices and minimum liquidity depth.
211    ///
212    /// Formula: `score = (product of all spot_price) × min(depths)`
213    ///
214    /// This accounts for:
215    /// - Spot price: the theoretical exchange rate along the path not accounting for slippage
216    /// - Fees: included in spot_price already
217    /// - Depth (inertia): minimum depth acts as a liquidity bottleneck indicator
218    ///
219    /// Returns `None` if the path cannot be scored (empty path or missing edge weights).
220    /// Paths that return `None` are filtered out of simulation.
221    ///
222    /// Higher score = better path candidate. Paths through deeper pools rank higher.
223    fn try_score_path(path: &Path<DepthAndPrice>) -> Option<f64> {
224        if path.is_empty() {
225            trace!("cannot score empty path");
226            return None;
227        }
228
229        let mut price = 1.0;
230        let mut min_depth = f64::MAX;
231
232        for edge in path.edge_iter() {
233            let Some(data) = edge.data.as_ref() else {
234                debug!(component_id = %edge.component_id, "edge missing weight data, path cannot be scored");
235                return None;
236            };
237
238            price *= data.spot_price;
239            min_depth = min_depth.min(data.depth);
240        }
241
242        Some(price * min_depth)
243    }
244
245    /// Simulates swaps along a path using each pool's `ProtocolSim::get_amount_out`.
246    /// Tracks intermediate state changes to handle routes that revisit the same pool.
247    ///
248    /// Calculates `net_amount_out` by subtracting gas cost from the output amount.
249    /// The result can be negative if gas cost exceeds output (e.g., inaccurate gas estimation).
250    ///
251    /// # Arguments
252    /// * `path` - The edge path to simulate
253    /// * `graph` - The graph containing edge and node data
254    /// * `market` - Market data for token/component lookups and gas price
255    /// * `token_prices` - Optional token prices for gas cost conversion
256    /// * `amount_in` - The input amount to simulate
257    #[instrument(level = "trace", skip(path, market, token_prices), fields(hop_count = path.len()))]
258    pub(crate) fn simulate_path<D>(
259        path: &Path<D>,
260        market: &SharedMarketData,
261        token_prices: Option<&TokenGasPrices>,
262        amount_in: BigUint,
263    ) -> Result<RouteResult, AlgorithmError> {
264        let mut current_amount = amount_in.clone();
265        let mut swaps = Vec::with_capacity(path.len());
266
267        // Track state overrides for pools we've already swapped through.
268        let mut state_overrides: HashMap<&ComponentId, Box<dyn ProtocolSim>> = HashMap::new();
269
270        for (address_in, edge_data, address_out) in path.iter() {
271            // Get token and component data for the simulation call
272            let token_in = market
273                .get_token(address_in)
274                .ok_or_else(|| AlgorithmError::DataNotFound {
275                    kind: "token",
276                    id: Some(format!("{:?}", address_in)),
277                })?;
278            let token_out = market
279                .get_token(address_out)
280                .ok_or_else(|| AlgorithmError::DataNotFound {
281                    kind: "token",
282                    id: Some(format!("{:?}", address_out)),
283                })?;
284
285            let component_id = &edge_data.component_id;
286            let component = market
287                .get_component(component_id)
288                .ok_or_else(|| AlgorithmError::DataNotFound {
289                    kind: "component",
290                    id: Some(component_id.clone()),
291                })?;
292            let component_state = market
293                .get_simulation_state(component_id)
294                .ok_or_else(|| AlgorithmError::DataNotFound {
295                    kind: "simulation state",
296                    id: Some(component_id.clone()),
297                })?;
298
299            let state = state_overrides
300                .get(component_id)
301                .map(Box::as_ref)
302                .unwrap_or(component_state);
303
304            // Simulate the swap
305            let result = state
306                .get_amount_out(current_amount.clone(), token_in, token_out)
307                .map_err(|e| AlgorithmError::Other(format!("simulation error: {:?}", e)))?;
308
309            // Record the swap
310            swaps.push(Swap::new(
311                component_id.clone(),
312                component.protocol_system.clone(),
313                token_in.address.clone(),
314                token_out.address.clone(),
315                current_amount.clone(),
316                result.amount.clone(),
317                result.gas,
318                component.clone(),
319                state.clone_box(),
320            ));
321
322            state_overrides.insert(component_id, result.new_state);
323            current_amount = result.amount;
324        }
325
326        // Calculate net amount out (output - gas cost in output token terms)
327        let route = Route::new(swaps);
328        let output_amount = route
329            .swaps()
330            .last()
331            .map(|s| s.amount_out().clone())
332            .unwrap_or_else(|| BigUint::ZERO);
333
334        let gas_price = market
335            .gas_price()
336            .ok_or(AlgorithmError::DataNotFound { kind: "gas price", id: None })?
337            .effective_gas_price()
338            .clone();
339
340        let net_amount_out = if let Some(last_swap) = route.swaps().last() {
341            let total_gas = route.total_gas();
342            let gas_cost_wei = &total_gas * &gas_price;
343
344            // Convert gas cost to output token terms using token prices
345            let gas_cost_in_output_token: Option<BigUint> = token_prices
346                .and_then(|prices| prices.get(last_swap.token_out()))
347                .map(|price| {
348                    // gas_cost_in_token = gas_cost_wei * numerator / denominator
349                    // where numerator = tokens per ETH, denominator = 10^18 + path_gas
350                    &gas_cost_wei * &price.numerator / &price.denominator
351                });
352
353            match gas_cost_in_output_token {
354                Some(gas_cost) => BigInt::from(output_amount) - BigInt::from(gas_cost),
355                None => {
356                    // No token price available - use output amount as-is
357                    // This happens if derived data hasn't been computed yet
358                    BigInt::from(output_amount)
359                }
360            }
361        } else {
362            BigInt::from(output_amount)
363        };
364
365        Ok(RouteResult::new(route, net_amount_out, gas_price))
366    }
367}
368
369impl Default for MostLiquidAlgorithm {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375impl Algorithm for MostLiquidAlgorithm {
376    type GraphType = StableDiGraph<DepthAndPrice>;
377    type GraphManager = PetgraphStableDiGraphManager<DepthAndPrice>;
378
379    fn name(&self) -> &str {
380        "most_liquid"
381    }
382
383    // TODO: Consider adding token pair symbols to the span for easier interpretation
384    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
385    async fn find_best_route(
386        &self,
387        graph: &Self::GraphType,
388        market: SharedMarketDataRef,
389        derived: Option<SharedDerivedDataRef>,
390        order: &Order,
391    ) -> Result<RouteResult, AlgorithmError> {
392        let start = Instant::now();
393
394        // Exact-out isn't supported yet
395        if !order.is_sell() {
396            return Err(AlgorithmError::ExactOutNotSupported);
397        }
398
399        // Extract token prices from derived data (if available)
400        let token_prices = if let Some(ref derived) = derived {
401            derived
402                .read()
403                .await
404                .token_prices()
405                .cloned()
406        } else {
407            None
408        };
409
410        let amount_in = order.amount().clone();
411
412        // Step 1: Find all edge paths using BFS (shorter paths first)
413        let all_paths = Self::find_paths(
414            graph,
415            order.token_in(),
416            order.token_out(),
417            self.min_hops,
418            self.max_hops,
419        )?;
420
421        let paths_candidates = all_paths.len();
422        if paths_candidates == 0 {
423            return Err(AlgorithmError::NoPath {
424                from: order.token_in().clone(),
425                to: order.token_out().clone(),
426                reason: NoPathReason::NoGraphPath,
427            });
428        }
429
430        // Step 2: Score and sort all paths by estimated output (higher score = better)
431        // No lock needed — scoring uses only local graph data.
432        let mut scored_paths: Vec<(Path<DepthAndPrice>, f64)> = all_paths
433            .into_iter()
434            .filter_map(|path| {
435                let score = Self::try_score_path(&path)?;
436                Some((path, score))
437            })
438            .collect();
439
440        scored_paths.sort_by(|(_, a_score), (_, b_score)| {
441            // Flip the comparison to get descending order
442            b_score
443                .partial_cmp(a_score)
444                .unwrap_or(std::cmp::Ordering::Equal)
445        });
446
447        if let Some(max_routes) = self.max_routes {
448            scored_paths.truncate(max_routes);
449        }
450
451        let paths_to_simulate = scored_paths.len();
452        let scoring_failures = paths_candidates - paths_to_simulate;
453        if paths_to_simulate == 0 {
454            return Err(AlgorithmError::NoPath {
455                from: order.token_in().clone(),
456                to: order.token_out().clone(),
457                reason: NoPathReason::NoScorablePaths,
458            });
459        }
460
461        // Step 3: Extract component IDs from all paths we'll simulate
462        let component_ids: HashSet<ComponentId> = scored_paths
463            .iter()
464            .flat_map(|(path, _)| {
465                path.edge_iter()
466                    .iter()
467                    .map(|e| e.component_id.clone())
468            })
469            .collect();
470
471        // Step 4: Brief lock — check gas price + extract market subset for simulation
472        let market = {
473            let market = market.read().await;
474            if market.gas_price().is_none() {
475                return Err(AlgorithmError::DataNotFound { kind: "gas price", id: None });
476            }
477            let market_subset = market.extract_subset(&component_ids);
478            drop(market);
479            market_subset
480        };
481
482        let mut paths_simulated = 0usize;
483        let mut simulation_failures = 0usize;
484
485        // Step 5: Simulate all paths in score order using the local market subset
486        let mut best: Option<RouteResult> = None;
487        let timeout_ms = self.timeout.as_millis() as u64;
488
489        for (edge_path, _) in scored_paths {
490            // Check timeout
491            let elapsed_ms = start.elapsed().as_millis() as u64;
492            if elapsed_ms > timeout_ms {
493                break;
494            }
495
496            let result = match Self::simulate_path(
497                &edge_path,
498                &market,
499                token_prices.as_ref(),
500                amount_in.clone(),
501            ) {
502                Ok(r) => r,
503                Err(e) => {
504                    trace!(error = %e, "simulation failed for path");
505                    simulation_failures += 1;
506                    continue;
507                }
508            };
509
510            // Check if this is the best result so far
511            if best
512                .as_ref()
513                .map(|best| result.net_amount_out() > best.net_amount_out())
514                .unwrap_or(true)
515            {
516                best = Some(result);
517            }
518
519            paths_simulated += 1;
520        }
521
522        // Log solve result
523        let solve_time_ms = start.elapsed().as_millis() as u64;
524        let block_number = market
525            .last_updated()
526            .map(|b| b.number());
527        // The proportion of paths simulated to total paths that we filtered to simulate
528        let coverage_pct = if paths_to_simulate == 0 {
529            100.0
530        } else {
531            (paths_simulated as f64 / paths_to_simulate as f64) * 100.0
532        };
533
534        // Record metrics
535        counter!("algorithm.scoring_failures").increment(scoring_failures as u64);
536        counter!("algorithm.simulation_failures").increment(simulation_failures as u64);
537        histogram!("algorithm.simulation_coverage_pct").record(coverage_pct);
538
539        match &best {
540            Some(result) => {
541                let tokens = market.token_registry_ref();
542                let path_desc = result.route().path_description(tokens);
543                let protocols = result
544                    .route()
545                    .swaps()
546                    .iter()
547                    .map(|s| s.protocol())
548                    .collect::<Vec<_>>();
549
550                let price = amount_in
551                    .to_f64()
552                    .filter(|&v| v > 0.0)
553                    .and_then(|amt_in| {
554                        result
555                            .net_amount_out()
556                            .to_f64()
557                            .map(|amt_out| amt_out / amt_in)
558                    })
559                    .unwrap_or(f64::NAN);
560
561                debug!(
562                    solve_time_ms,
563                    block_number,
564                    paths_candidates,
565                    paths_to_simulate,
566                    paths_simulated,
567                    simulation_failures,
568                    simulation_coverage_pct = coverage_pct,
569                    components_considered = component_ids.len(),
570                    tokens_considered = market.token_registry_ref().len(),
571                    path = %path_desc,
572                    amount_in = %amount_in,
573                    net_amount_out = %result.net_amount_out(),
574                    price_out_per_in = price,
575                    hop_count = result.route().swaps().len(),
576                    protocols = ?protocols,
577                    "route found"
578                );
579            }
580            None => {
581                debug!(
582                    solve_time_ms,
583                    block_number,
584                    paths_candidates,
585                    paths_to_simulate,
586                    paths_simulated,
587                    simulation_failures,
588                    simulation_coverage_pct = coverage_pct,
589                    components_considered = component_ids.len(),
590                    tokens_considered = market.token_registry_ref().len(),
591                    "no viable route"
592                );
593            }
594        }
595
596        best.ok_or({
597            if solve_time_ms > timeout_ms {
598                AlgorithmError::Timeout { elapsed_ms: solve_time_ms }
599            } else {
600                AlgorithmError::InsufficientLiquidity
601            }
602        })
603    }
604
605    fn computation_requirements(&self) -> ComputationRequirements {
606        // MostLiquidAlgorithm uses token prices to convert gas costs from wei
607        // to output token terms for accurate amount_out_net_gas calculation.
608        //
609        // Token prices are marked as `allow_stale` since they don't change much
610        // block-to-block and having slightly stale prices is acceptable for
611        // gas cost estimation.
612        ComputationRequirements::none()
613            .allow_stale("token_prices")
614            .expect("Conflicting Computation Requirements")
615    }
616
617    fn timeout(&self) -> Duration {
618        self.timeout
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use std::{collections::HashSet, sync::Arc};
625
626    use rstest::rstest;
627    use tokio::sync::RwLock;
628    use tycho_simulation::{
629        tycho_core::simulation::protocol_sim::Price,
630        tycho_ethereum::gas::{BlockGasPrice, GasPrice},
631    };
632
633    use super::*;
634    use crate::{
635        algorithm::test_utils::{
636            addr, component,
637            fixtures::{addrs, diamond_graph, linear_graph, parallel_graph},
638            market_read, order, setup_market, token, MockProtocolSim, ONE_ETH,
639        },
640        derived::{types::TokenGasPrices, DerivedData},
641        graph::GraphManager,
642        types::OrderSide,
643    };
644
645    fn wrap_market(market: SharedMarketData) -> SharedMarketDataRef {
646        Arc::new(RwLock::new(market))
647    }
648
649    /// Creates a SharedDerivedDataRef with token prices set for testing.
650    ///
651    /// The price is set to numerator=1, denominator=1, which means:
652    /// gas_cost_in_token = gas_cost_wei * 1 / 1 = gas_cost_wei
653    fn setup_derived_with_token_prices(token_addresses: &[Address]) -> SharedDerivedDataRef {
654        let mut token_prices: TokenGasPrices = HashMap::new();
655        for addr in token_addresses {
656            // Price where 1 wei of gas = 1 unit of token
657            token_prices.insert(
658                addr.clone(),
659                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
660            );
661        }
662
663        let mut derived_data = DerivedData::new();
664        derived_data.set_token_prices(token_prices, 1);
665        Arc::new(RwLock::new(derived_data))
666    }
667    // ==================== try_score_path Tests ====================
668
669    #[test]
670    fn test_try_score_path_calculates_correctly() {
671        let (a, b, c, _) = addrs();
672        let mut m = linear_graph();
673
674        // A->B: spot=2.0, depth=1000, fee=0.3%; B->C: spot=0.5, depth=500, fee=0.1%
675        m.set_edge_weight(&"ab".to_string(), &a, &b, DepthAndPrice::new(2.0, 1000.0), false)
676            .unwrap();
677        m.set_edge_weight(&"bc".to_string(), &b, &c, DepthAndPrice::new(0.5, 500.0), false)
678            .unwrap();
679
680        // Use find_paths to get the 2-hop path A->B->C
681        let graph = m.graph();
682        let paths = MostLiquidAlgorithm::find_paths(graph, &a, &c, 2, 2).unwrap();
683        assert_eq!(paths.len(), 1);
684        let path = &paths[0];
685
686        // price = 2.0 * 0.997 * 0.5 * 0.999, min_depth = 500.0
687        let expected = 2.0 * 0.5 * 500.0;
688        let score = MostLiquidAlgorithm::try_score_path(path).unwrap();
689        assert_eq!(score, expected, "expected {expected}, got {score}");
690    }
691
692    #[test]
693    fn test_try_score_path_empty_returns_none() {
694        let path: Path<DepthAndPrice> = Path::new();
695        assert_eq!(MostLiquidAlgorithm::try_score_path(&path), None);
696    }
697
698    #[test]
699    fn test_try_score_path_missing_weight_returns_none() {
700        let (a, b, _, _) = addrs();
701        let m = linear_graph();
702        let graph = m.graph();
703        let paths = MostLiquidAlgorithm::find_paths(graph, &a, &b, 1, 1).unwrap();
704        assert_eq!(paths.len(), 1);
705        assert!(MostLiquidAlgorithm::try_score_path(&paths[0]).is_none());
706    }
707
708    #[test]
709    fn test_try_score_path_circular_route() {
710        // Test scoring a circular path A -> B -> A
711        let (a, b, _, _) = addrs();
712        let mut m = linear_graph();
713
714        // Set weights for both directions of the ab pool
715        // A->B: spot=2.0, depth=1000, fee=0.3%
716        // B->A: spot=0.6, depth=800, fee=0.3%
717        m.set_edge_weight(&"ab".to_string(), &a, &b, DepthAndPrice::new(2.0, 1000.0), false)
718            .unwrap();
719        m.set_edge_weight(&"ab".to_string(), &b, &a, DepthAndPrice::new(0.6, 800.0), false)
720            .unwrap();
721
722        let graph = m.graph();
723        // Find A->B->A paths (circular, 2 hops)
724        let paths = MostLiquidAlgorithm::find_paths(graph, &a, &a, 2, 2).unwrap();
725
726        // Should find at least one path
727        assert_eq!(paths.len(), 1);
728
729        // Score should be: price * min_depth
730        // price = 2.0 * 0.997 * 0.6 * 0.997 = 1.1928...
731        // min_depth = min(1000, 800) = 800
732        // score = 1.1928 * 800 ≈ 954.3
733        let score = MostLiquidAlgorithm::try_score_path(&paths[0]).unwrap();
734        let expected = 2.0 * 0.6 * 800.0;
735        assert_eq!(score, expected, "expected {expected}, got {score}");
736    }
737
738    // ==================== find_paths Tests ====================
739
740    fn all_ids(paths: Vec<Path<'_, DepthAndPrice>>) -> HashSet<Vec<&str>> {
741        paths
742            .iter()
743            .map(|p| {
744                p.iter()
745                    .map(|(_, e, _)| e.component_id.as_str())
746                    .collect()
747            })
748            .collect()
749    }
750
751    #[test]
752    fn test_find_paths_linear_forward_and_reverse() {
753        let (a, b, c, d) = addrs();
754        let m = linear_graph();
755        let g = m.graph();
756
757        // Forward: A->B (1 hop), A->C (2 hops), A->D (3 hops)
758        let p = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 1).unwrap();
759        assert_eq!(all_ids(p), HashSet::from([vec!["ab"]]));
760
761        let p = MostLiquidAlgorithm::find_paths(g, &a, &c, 1, 2).unwrap();
762        assert_eq!(all_ids(p), HashSet::from([vec!["ab", "bc"]]));
763
764        let p = MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 3).unwrap();
765        assert_eq!(all_ids(p), HashSet::from([vec!["ab", "bc", "cd"]]));
766
767        // Reverse: D->A (bidirectional pools)
768        let p = MostLiquidAlgorithm::find_paths(g, &d, &a, 1, 3).unwrap();
769        assert_eq!(all_ids(p), HashSet::from([vec!["cd", "bc", "ab"]]));
770    }
771
772    #[test]
773    fn test_find_paths_respects_hop_bounds() {
774        let (a, _, c, d) = addrs();
775        let m = linear_graph();
776        let g = m.graph();
777
778        // A->D needs 3 hops, max_hops=2 finds nothing
779        assert!(MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 2)
780            .unwrap()
781            .is_empty());
782
783        // A->C is 2 hops, min_hops=3 finds nothing
784        assert!(MostLiquidAlgorithm::find_paths(g, &a, &c, 3, 3)
785            .unwrap()
786            .is_empty());
787    }
788
789    #[test]
790    fn test_find_paths_parallel_pools() {
791        let (a, b, c, _) = addrs();
792        let m = parallel_graph();
793        let g = m.graph();
794
795        // A->B: 3 parallel pools = 3 paths
796        let p = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 1).unwrap();
797        assert_eq!(all_ids(p), HashSet::from([vec!["ab1"], vec!["ab2"], vec!["ab3"]]));
798
799        // A->C: 3 A->B pools × 2 B->C pools = 6 paths
800        let p = MostLiquidAlgorithm::find_paths(g, &a, &c, 1, 2).unwrap();
801        assert_eq!(
802            all_ids(p),
803            HashSet::from([
804                vec!["ab1", "bc1"],
805                vec!["ab1", "bc2"],
806                vec!["ab2", "bc1"],
807                vec!["ab2", "bc2"],
808                vec!["ab3", "bc1"],
809                vec!["ab3", "bc2"],
810            ])
811        );
812    }
813
814    #[test]
815    fn test_find_paths_diamond_multiple_routes() {
816        let (a, _, _, d) = addrs();
817        let m = diamond_graph();
818        let g = m.graph();
819
820        // A->D: two 2-hop paths
821        let p = MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 2).unwrap();
822        assert_eq!(all_ids(p), HashSet::from([vec!["ab", "bd"], vec!["ac", "cd"]]));
823    }
824
825    #[test]
826    fn test_find_paths_no_intermediate_cycles() {
827        let (a, b, _, _) = addrs();
828        let m = linear_graph();
829        let g = m.graph();
830
831        // A->B with max_hops=3: only the direct 1-hop path is valid.
832        // Revisit paths like A->B->C->B or A->B->B->B are pruned because
833        // they create intermediate cycles unsupported by Tycho execution
834        // (only first == last cycles are allowed, i.e. from == to).
835        let p = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 3).unwrap();
836        assert_eq!(all_ids(p), HashSet::from([vec!["ab"]]));
837    }
838
839    #[test]
840    fn test_find_paths_cyclic_same_source_dest() {
841        let (a, _, _, _) = addrs();
842        // Use parallel_graph with 3 A<->B pools to verify all combinations
843        let m = parallel_graph();
844        let g = m.graph();
845
846        // A->A (cyclic path) with 2 hops: should find all 9 combinations (3 pools × 3 pools)
847        // Note: min_hops=2 because cyclic paths require at least 2 hops
848        let p = MostLiquidAlgorithm::find_paths(g, &a, &a, 2, 2).unwrap();
849        assert_eq!(
850            all_ids(p),
851            HashSet::from([
852                vec!["ab1", "ab1"],
853                vec!["ab1", "ab2"],
854                vec!["ab1", "ab3"],
855                vec!["ab2", "ab1"],
856                vec!["ab2", "ab2"],
857                vec!["ab2", "ab3"],
858                vec!["ab3", "ab1"],
859                vec!["ab3", "ab2"],
860                vec!["ab3", "ab3"],
861            ])
862        );
863    }
864
865    #[rstest]
866    #[case::source_not_in_graph(false, true)]
867    #[case::dest_not_in_graph(true, false)]
868    fn test_find_paths_token_not_in_graph(#[case] from_exists: bool, #[case] to_exists: bool) {
869        // Graph contains tokens A (0x0A) and B (0x0B) from linear_graph fixture
870        let (a, b, _, _) = addrs();
871        let non_existent = addr(0x99);
872        let m = linear_graph();
873        let g = m.graph();
874
875        let from = if from_exists { a } else { non_existent.clone() };
876        let to = if to_exists { b } else { non_existent };
877
878        let result = MostLiquidAlgorithm::find_paths(g, &from, &to, 1, 3);
879
880        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
881    }
882
883    #[rstest]
884    #[case::min_greater_than_max(3, 1)]
885    #[case::min_hops_zero(0, 1)]
886    fn test_find_paths_invalid_configuration(#[case] min_hops: usize, #[case] max_hops: usize) {
887        let (a, b, _, _) = addrs();
888        let m = linear_graph();
889        let g = m.graph();
890
891        assert!(matches!(
892            MostLiquidAlgorithm::find_paths(g, &a, &b, min_hops, max_hops)
893                .err()
894                .unwrap(),
895            AlgorithmError::InvalidConfiguration { reason: _ }
896        ));
897    }
898
899    #[test]
900    fn test_find_paths_bfs_ordering() {
901        // Build a graph with 1-hop, 2-hop, and 3-hop paths to E:
902        //   A --[ae]--> E                          (1-hop)
903        //   A --[ab]--> B --[be]--> E              (2-hop)
904        //   A --[ac]--> C --[cd]--> D --[de]--> E  (3-hop)
905        let (a, b, c, d) = addrs();
906        let e = addr(0x0E);
907        let mut m = PetgraphStableDiGraphManager::<DepthAndPrice>::new();
908        let mut t = HashMap::new();
909        t.insert("ae".into(), vec![a.clone(), e.clone()]);
910        t.insert("ab".into(), vec![a.clone(), b.clone()]);
911        t.insert("be".into(), vec![b, e.clone()]);
912        t.insert("ac".into(), vec![a.clone(), c.clone()]);
913        t.insert("cd".into(), vec![c, d.clone()]);
914        t.insert("de".into(), vec![d, e.clone()]);
915        m.initialize_graph(&t);
916        let g = m.graph();
917
918        let p = MostLiquidAlgorithm::find_paths(g, &a, &e, 1, 3).unwrap();
919
920        // BFS guarantees paths are ordered by hop count
921        assert_eq!(p.len(), 3, "Expected 3 paths total");
922        assert_eq!(p[0].len(), 1, "First path should be 1-hop");
923        assert_eq!(p[1].len(), 2, "Second path should be 2-hop");
924        assert_eq!(p[2].len(), 3, "Third path should be 3-hop");
925    }
926
927    // ==================== simulate_path Tests ====================
928    //
929    // Note: These tests use MockProtocolSim which is detected as a "native" pool.
930    // Ideally we should also test VM pool state override behavior (vm_state_override),
931    // which shares state across all VM components. This would require a mock that
932    // downcasts to EVMPoolState<PreCachedDB>, or integration tests with real VM pools.
933
934    #[test]
935    fn test_simulate_path_single_hop() {
936        let token_a = token(0x01, "A");
937        let token_b = token(0x02, "B");
938
939        let (market, manager) =
940            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
941
942        let paths = MostLiquidAlgorithm::find_paths(
943            manager.graph(),
944            &token_a.address,
945            &token_b.address,
946            1,
947            1,
948        )
949        .unwrap();
950        let path = paths.into_iter().next().unwrap();
951
952        let result = MostLiquidAlgorithm::simulate_path(
953            &path,
954            &market_read(&market),
955            None,
956            BigUint::from(100u64),
957        )
958        .unwrap();
959
960        assert_eq!(result.route().swaps().len(), 1);
961        assert_eq!(*result.route().swaps()[0].amount_in(), BigUint::from(100u64));
962        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(200u64)); // 100 * 2
963        assert_eq!(result.route().swaps()[0].component_id(), "pool1");
964    }
965
966    #[test]
967    fn test_simulate_path_multi_hop_chains_amounts() {
968        let token_a = token(0x01, "A");
969        let token_b = token(0x02, "B");
970        let token_c = token(0x03, "C");
971
972        let (market, manager) = setup_market(vec![
973            ("pool1", &token_a, &token_b, MockProtocolSim::new(2.0)),
974            ("pool2", &token_b, &token_c, MockProtocolSim::new(3.0)),
975        ]);
976
977        let paths = MostLiquidAlgorithm::find_paths(
978            manager.graph(),
979            &token_a.address,
980            &token_c.address,
981            2,
982            2,
983        )
984        .unwrap();
985        let path = paths.into_iter().next().unwrap();
986
987        let result = MostLiquidAlgorithm::simulate_path(
988            &path,
989            &market_read(&market),
990            None,
991            BigUint::from(10u64),
992        )
993        .unwrap();
994
995        assert_eq!(result.route().swaps().len(), 2);
996        // First hop: 10 * 2 = 20
997        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(20u64));
998        // Second hop: 20 * 3 = 60
999        assert_eq!(*result.route().swaps()[1].amount_in(), BigUint::from(20u64));
1000        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(60u64));
1001    }
1002
1003    #[test]
1004    fn test_simulate_path_same_pool_twice_uses_updated_state() {
1005        // Route: A -> B -> A through the same pool
1006        // First swap uses multiplier=2, second should use multiplier=3 (updated state)
1007        let token_a = token(0x01, "A");
1008        let token_b = token(0x02, "B");
1009
1010        let (market, manager) =
1011            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1012
1013        // A->B->A path requires min_hops=2, max_hops=2
1014        // Since the graph is bidirectional, we should get A->B->A path
1015        let paths = MostLiquidAlgorithm::find_paths(
1016            manager.graph(),
1017            &token_a.address,
1018            &token_a.address,
1019            2,
1020            2,
1021        )
1022        .unwrap();
1023
1024        // Should only contain the A->B->A path
1025        assert_eq!(paths.len(), 1);
1026        let path = paths[0].clone();
1027
1028        let result = MostLiquidAlgorithm::simulate_path(
1029            &path,
1030            &market_read(&market),
1031            None,
1032            BigUint::from(10u64),
1033        )
1034        .unwrap();
1035
1036        assert_eq!(result.route().swaps().len(), 2);
1037        // First: 10 * 2 = 20
1038        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(20u64));
1039        // Second: 20 / 3 = 6 (state updated, multiplier incremented)
1040        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(6u64));
1041    }
1042
1043    #[test]
1044    fn test_simulate_path_missing_token_returns_data_not_found() {
1045        let token_a = token(0x01, "A");
1046        let token_b = token(0x02, "B");
1047        let token_c = token(0x03, "C");
1048
1049        let (market, _) =
1050            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1051        let market = market_read(&market);
1052
1053        // Add token C to graph but not to market (A->B->C)
1054        let mut topology = market.component_topology();
1055        topology
1056            .insert("pool2".to_string(), vec![token_b.address.clone(), token_c.address.clone()]);
1057        let mut manager = PetgraphStableDiGraphManager::default();
1058        manager.initialize_graph(&topology);
1059
1060        let graph = manager.graph();
1061        let paths =
1062            MostLiquidAlgorithm::find_paths(graph, &token_a.address, &token_c.address, 2, 2)
1063                .unwrap();
1064        let path = paths.into_iter().next().unwrap();
1065
1066        let result =
1067            MostLiquidAlgorithm::simulate_path(&path, &market, None, BigUint::from(100u64));
1068        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "token", .. })));
1069    }
1070
1071    #[test]
1072    fn test_simulate_path_missing_component_returns_data_not_found() {
1073        let token_a = token(0x01, "A");
1074        let token_b = token(0x02, "B");
1075        let (market, manager) =
1076            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1077
1078        // Remove the component but keep tokens and graph
1079        let mut market_write = market.try_write().unwrap();
1080        market_write.remove_components([&"pool1".to_string()]);
1081        drop(market_write);
1082
1083        let graph = manager.graph();
1084        let paths =
1085            MostLiquidAlgorithm::find_paths(graph, &token_a.address, &token_b.address, 1, 1)
1086                .unwrap();
1087        let path = paths.into_iter().next().unwrap();
1088
1089        let result = MostLiquidAlgorithm::simulate_path(
1090            &path,
1091            &market_read(&market),
1092            None,
1093            BigUint::from(100u64),
1094        );
1095        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "component", .. })));
1096    }
1097
1098    // ==================== find_best_route Tests ====================
1099
1100    #[tokio::test]
1101    async fn test_find_best_route_single_path() {
1102        let token_a = token(0x01, "A");
1103        let token_b = token(0x02, "B");
1104
1105        let (market, manager) =
1106            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1107
1108        let algorithm = MostLiquidAlgorithm::with_config(
1109            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1110        )
1111        .unwrap();
1112        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1113        let result = algorithm
1114            .find_best_route(manager.graph(), market, None, &order)
1115            .await
1116            .unwrap();
1117
1118        assert_eq!(result.route().swaps().len(), 1);
1119        assert_eq!(*result.route().swaps()[0].amount_in(), BigUint::from(ONE_ETH));
1120        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1121    }
1122
1123    #[tokio::test]
1124    async fn test_find_best_route_ranks_by_net_amount_out() {
1125        // Tests that route selection is based on net_amount_out (output - gas cost),
1126        // not just gross output. Three parallel pools with different spot_price/gas combos:
1127        //
1128        // Gas price = 100 wei/gas (set by setup_market)
1129        //
1130        // | Pool      | spot_price | gas | Output (1000 in) | Gas Cost (gas*100) | Net   |
1131        // |-----------|------------|-----|------------------|-------------------|-------|
1132        // | best      | 3          | 10  | 3000             | 1000              | 2000  |
1133        // | low_out   | 2          | 5   | 2000             | 500               | 1500  |
1134        // | high_gas  | 4          | 30  | 4000             | 3000              | 1000  |
1135        let token_a = token(0x01, "A");
1136        let token_b = token(0x02, "B");
1137
1138        let (market, manager) = setup_market(vec![
1139            ("best", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(10)),
1140            ("low_out", &token_a, &token_b, MockProtocolSim::new(2.0).with_gas(5)),
1141            ("high_gas", &token_a, &token_b, MockProtocolSim::new(4.0).with_gas(30)),
1142        ]);
1143
1144        let algorithm = MostLiquidAlgorithm::with_config(
1145            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1146        )
1147        .unwrap();
1148        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
1149
1150        // Set up derived data with token prices so gas can be deducted
1151        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1152
1153        let result = algorithm
1154            .find_best_route(manager.graph(), market, Some(derived), &order)
1155            .await
1156            .unwrap();
1157
1158        // Should select "best" pool for highest net_amount_out (2000)
1159        assert_eq!(result.route().swaps().len(), 1);
1160        assert_eq!(result.route().swaps()[0].component_id(), "best");
1161        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(3000u64));
1162        assert_eq!(result.net_amount_out(), &BigInt::from(2000)); // 3000 - 1000
1163    }
1164
1165    #[tokio::test]
1166    async fn test_find_best_route_no_path_returns_error() {
1167        let token_a = token(0x01, "A");
1168        let token_b = token(0x02, "B");
1169        let token_c = token(0x03, "C"); // Disconnected
1170
1171        let (market, manager) =
1172            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1173
1174        let algorithm = MostLiquidAlgorithm::new();
1175        let order = order(&token_a, &token_c, ONE_ETH, OrderSide::Sell);
1176
1177        let result = algorithm
1178            .find_best_route(manager.graph(), market, None, &order)
1179            .await;
1180        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
1181    }
1182
1183    #[tokio::test]
1184    async fn test_find_best_route_multi_hop() {
1185        let token_a = token(0x01, "A");
1186        let token_b = token(0x02, "B");
1187        let token_c = token(0x03, "C");
1188
1189        let (market, manager) = setup_market(vec![
1190            ("pool1", &token_a, &token_b, MockProtocolSim::new(2.0)),
1191            ("pool2", &token_b, &token_c, MockProtocolSim::new(3.0)),
1192        ]);
1193
1194        let algorithm = MostLiquidAlgorithm::with_config(
1195            AlgorithmConfig::new(1, 2, Duration::from_millis(100), None).unwrap(),
1196        )
1197        .unwrap();
1198        let order = order(&token_a, &token_c, ONE_ETH, OrderSide::Sell);
1199
1200        let result = algorithm
1201            .find_best_route(manager.graph(), market, None, &order)
1202            .await
1203            .unwrap();
1204
1205        // A->B: ONE_ETH*2, B->C: (ONE_ETH*2)*3
1206        assert_eq!(result.route().swaps().len(), 2);
1207        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1208        assert_eq!(result.route().swaps()[0].component_id(), "pool1".to_string());
1209        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(ONE_ETH * 2 * 3));
1210        assert_eq!(result.route().swaps()[1].component_id(), "pool2".to_string());
1211    }
1212
1213    #[tokio::test]
1214    async fn test_find_best_route_skips_paths_without_edge_weights() {
1215        // Pool1 has edge weights (scoreable), Pool2 doesn't (filtered out during scoring)
1216        let token_a = token(0x01, "A");
1217        let token_b = token(0x02, "B");
1218
1219        // Set up market with both pools using new API
1220        let mut market = SharedMarketData::new();
1221        let pool1_state = MockProtocolSim::new(2.0);
1222        let pool2_state = MockProtocolSim::new(3.0); // Higher multiplier but no edge weight
1223
1224        let pool1_comp = component("pool1", &[token_a.clone(), token_b.clone()]);
1225        let pool2_comp = component("pool2", &[token_a.clone(), token_b.clone()]);
1226
1227        // Set gas price (required for simulation)
1228        market.update_gas_price(BlockGasPrice {
1229            block_number: 1,
1230            block_hash: Default::default(),
1231            block_timestamp: 0,
1232            pricing: GasPrice::Legacy { gas_price: BigUint::from(1u64) },
1233        });
1234
1235        // Insert components
1236        market.upsert_components(vec![pool1_comp, pool2_comp]);
1237
1238        // Insert states
1239        market.update_states(vec![
1240            ("pool1".to_string(), Box::new(pool1_state.clone()) as Box<dyn ProtocolSim>),
1241            ("pool2".to_string(), Box::new(pool2_state) as Box<dyn ProtocolSim>),
1242        ]);
1243
1244        // Insert tokens
1245        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1246
1247        // Initialize graph with both pools
1248        let mut manager = PetgraphStableDiGraphManager::default();
1249        manager.initialize_graph(&market.component_topology());
1250
1251        // Only set edge weights for pool1, NOT pool2
1252        let weight = DepthAndPrice::from_protocol_sim(&pool1_state, &token_a, &token_b).unwrap();
1253        manager
1254            .set_edge_weight(
1255                &"pool1".to_string(),
1256                &token_a.address,
1257                &token_b.address,
1258                weight,
1259                false,
1260            )
1261            .unwrap();
1262
1263        // Use max_hops=1 to focus only on direct 1-hop paths
1264        let algorithm = MostLiquidAlgorithm::with_config(
1265            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1266        )
1267        .unwrap();
1268        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1269        let market = wrap_market(market);
1270        let result = algorithm
1271            .find_best_route(manager.graph(), market, None, &order)
1272            .await
1273            .unwrap();
1274
1275        // Should use pool1 (only scoreable path), despite pool2 having better multiplier
1276        assert_eq!(result.route().swaps().len(), 1);
1277        assert_eq!(result.route().swaps()[0].component_id(), "pool1");
1278        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1279    }
1280
1281    #[tokio::test]
1282    async fn test_find_best_route_no_scorable_paths() {
1283        // All paths exist but none have edge weights (can't be scored)
1284        let token_a = token(0x01, "A");
1285        let token_b = token(0x02, "B");
1286
1287        let mut market = SharedMarketData::new();
1288        let pool_state = MockProtocolSim::new(2.0);
1289        let pool_comp = component("pool1", &[token_a.clone(), token_b.clone()]);
1290
1291        // Set gas price (required for simulation)
1292        market.update_gas_price(BlockGasPrice {
1293            block_number: 1,
1294            block_hash: Default::default(),
1295            block_timestamp: 0,
1296            pricing: GasPrice::Eip1559 {
1297                base_fee_per_gas: BigUint::from(1u64),
1298                max_priority_fee_per_gas: BigUint::from(0u64),
1299            },
1300        });
1301
1302        market.upsert_components(vec![pool_comp]);
1303        market.update_states(vec![(
1304            "pool1".to_string(),
1305            Box::new(pool_state) as Box<dyn ProtocolSim>,
1306        )]);
1307        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1308
1309        // Initialize graph but DO NOT set any edge weights
1310        let mut manager = PetgraphStableDiGraphManager::default();
1311        manager.initialize_graph(&market.component_topology());
1312
1313        let algorithm = MostLiquidAlgorithm::new();
1314        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1315        let market = wrap_market(market);
1316
1317        let result = algorithm
1318            .find_best_route(manager.graph(), market, None, &order)
1319            .await;
1320        assert!(matches!(
1321            result,
1322            Err(AlgorithmError::NoPath { reason: NoPathReason::NoScorablePaths, .. })
1323        ));
1324    }
1325
1326    #[tokio::test]
1327    async fn test_find_best_route_gas_exceeds_output_returns_negative_net() {
1328        let token_a = token(0x01, "A");
1329        let token_b = token(0x02, "B");
1330
1331        let (market, manager) =
1332            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1333        let mut market_write = market.try_write().unwrap();
1334
1335        // Set a non-zero gas price so gas cost exceeds tiny output
1336        // gas_cost = 50_000 * (1_000_000 + 1_000_000) = 100_000_000_000 >> 2 wei output
1337        market_write.update_gas_price(BlockGasPrice {
1338            block_number: 1,
1339            block_hash: Default::default(),
1340            block_timestamp: 0,
1341            pricing: GasPrice::Eip1559 {
1342                base_fee_per_gas: BigUint::from(1_000_000u64),
1343                max_priority_fee_per_gas: BigUint::from(1_000_000u64),
1344            },
1345        });
1346        drop(market_write); // Release write lock
1347
1348        let algorithm = MostLiquidAlgorithm::new();
1349        let order = order(&token_a, &token_b, 1, OrderSide::Sell); // 1 wei input -> 2 wei output
1350
1351        // Set up derived data with token prices so gas can be deducted
1352        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1353
1354        // Route should still be returned, but with negative net_amount_out
1355        let result = algorithm
1356            .find_best_route(manager.graph(), market, Some(derived), &order)
1357            .await
1358            .expect("should return route even with negative net_amount_out");
1359
1360        // Verify the route has swaps
1361        assert_eq!(result.route().swaps().len(), 1);
1362        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(2u64)); // 1 * 2 = 2 wei
1363
1364        // Verify it's: 2 - 200_000_000_000 = -199_999_999_998
1365        let expected_net = BigInt::from(2) - BigInt::from(100_000_000_000u64);
1366        assert_eq!(result.net_amount_out(), &expected_net);
1367    }
1368
1369    #[tokio::test]
1370    async fn test_find_best_route_insufficient_liquidity() {
1371        // Pool has limited liquidity (1000 wei) but we try to swap ONE_ETH
1372        let token_a = token(0x01, "A");
1373        let token_b = token(0x02, "B");
1374
1375        let (market, manager) = setup_market(vec![(
1376            "pool1",
1377            &token_a,
1378            &token_b,
1379            MockProtocolSim::new(2.0).with_liquidity(1000),
1380        )]);
1381
1382        let algorithm = MostLiquidAlgorithm::new();
1383        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell); // More than 1000 wei liquidity
1384
1385        let result = algorithm
1386            .find_best_route(manager.graph(), market, None, &order)
1387            .await;
1388        assert!(matches!(result, Err(AlgorithmError::InsufficientLiquidity)));
1389    }
1390
1391    #[tokio::test]
1392    async fn test_find_best_route_missing_gas_price_returns_error() {
1393        // Test that missing gas price returns DataNotFound error, not InsufficientLiquidity
1394        let token_a = token(0x01, "A");
1395        let token_b = token(0x02, "B");
1396
1397        let mut market = SharedMarketData::new();
1398        let pool_state = MockProtocolSim::new(2.0);
1399        let pool_comp = component("pool1", &[token_a.clone(), token_b.clone()]);
1400
1401        // DO NOT set gas price - this is what we're testing
1402        market.upsert_components(vec![pool_comp]);
1403        market.update_states(vec![(
1404            "pool1".to_string(),
1405            Box::new(pool_state.clone()) as Box<dyn ProtocolSim>,
1406        )]);
1407        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1408
1409        // Initialize graph and set edge weights
1410        let mut manager = PetgraphStableDiGraphManager::default();
1411        manager.initialize_graph(&market.component_topology());
1412        let weight = DepthAndPrice::from_protocol_sim(&pool_state, &token_a, &token_b).unwrap();
1413        manager
1414            .set_edge_weight(
1415                &"pool1".to_string(),
1416                &token_a.address,
1417                &token_b.address,
1418                weight,
1419                false,
1420            )
1421            .unwrap();
1422
1423        let algorithm = MostLiquidAlgorithm::new();
1424        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1425        let market = wrap_market(market);
1426
1427        let result = algorithm
1428            .find_best_route(manager.graph(), market, None, &order)
1429            .await;
1430
1431        // Should get DataNotFound for gas price, not InsufficientLiquidity
1432        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "gas price", .. })));
1433    }
1434
1435    #[tokio::test]
1436    async fn test_find_best_route_circular_arbitrage() {
1437        let token_a = token(0x01, "A");
1438        let token_b = token(0x02, "B");
1439
1440        // MockProtocolSim::get_amount_out multiplies by spot_price when token_in < token_out.
1441        // After the first swap, spot_price increments to 3.
1442        let (market, manager) =
1443            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1444
1445        // Use min_hops=2 to require at least 2 hops (circular)
1446        let algorithm = MostLiquidAlgorithm::with_config(
1447            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
1448        )
1449        .unwrap();
1450
1451        // Order: swap A for A (circular)
1452        let order = order(&token_a, &token_a, 100, OrderSide::Sell);
1453
1454        let result = algorithm
1455            .find_best_route(manager.graph(), market, None, &order)
1456            .await
1457            .unwrap();
1458
1459        // Should have 2 swaps forming a circle
1460        assert_eq!(result.route().swaps().len(), 2, "Should have 2 swaps for circular route");
1461
1462        // First swap: A -> B (100 * 2 = 200)
1463        assert_eq!(*result.route().swaps()[0].token_in(), token_a.address);
1464        assert_eq!(*result.route().swaps()[0].token_out(), token_b.address);
1465        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(200u64));
1466
1467        // Second swap: B -> A (200 / 3 = 66, spot_price incremented to 3)
1468        assert_eq!(*result.route().swaps()[1].token_in(), token_b.address);
1469        assert_eq!(*result.route().swaps()[1].token_out(), token_a.address);
1470        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(66u64));
1471
1472        // Verify the route starts and ends with the same token
1473        assert_eq!(result.route().swaps()[0].token_in(), result.route().swaps()[1].token_out());
1474    }
1475
1476    #[tokio::test]
1477    async fn test_find_best_route_respects_min_hops() {
1478        // Setup: A->B (1-hop) and A->C->B (2-hop)
1479        // With min_hops=2, should only return the 2-hop path
1480        let token_a = token(0x01, "A");
1481        let token_b = token(0x02, "B");
1482        let token_c = token(0x03, "C");
1483
1484        let (market, manager) = setup_market(vec![
1485            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(10.0)), /* Direct: 1-hop, high
1486                                                                          * output */
1487            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0)), // 2-hop path
1488            ("pool_cb", &token_c, &token_b, MockProtocolSim::new(3.0)), // 2-hop path
1489        ]);
1490
1491        // min_hops=2 should skip the 1-hop direct path
1492        let algorithm = MostLiquidAlgorithm::with_config(
1493            AlgorithmConfig::new(2, 3, Duration::from_millis(100), None).unwrap(),
1494        )
1495        .unwrap();
1496        let order = order(&token_a, &token_b, 100, OrderSide::Sell);
1497
1498        // Set up derived data with token prices so gas can be deducted
1499        // This ensures shorter paths are preferred due to lower gas cost
1500        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1501
1502        let result = algorithm
1503            .find_best_route(manager.graph(), market, Some(derived), &order)
1504            .await
1505            .unwrap();
1506
1507        // Should use 2-hop path (A->C->B), not the direct 1-hop path
1508        assert_eq!(result.route().swaps().len(), 2, "Should use 2-hop path due to min_hops=2");
1509        assert_eq!(result.route().swaps()[0].component_id(), "pool_ac");
1510        assert_eq!(result.route().swaps()[1].component_id(), "pool_cb");
1511    }
1512
1513    #[tokio::test]
1514    async fn test_find_best_route_respects_max_hops() {
1515        // Setup: Only path is A->B->C (2 hops)
1516        // With max_hops=1, should return NoPath error
1517        let token_a = token(0x01, "A");
1518        let token_b = token(0x02, "B");
1519        let token_c = token(0x03, "C");
1520
1521        let (market, manager) = setup_market(vec![
1522            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1523            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1524        ]);
1525
1526        // max_hops=1 cannot reach C from A (needs 2 hops)
1527        let algorithm = MostLiquidAlgorithm::with_config(
1528            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1529        )
1530        .unwrap();
1531        let order = order(&token_a, &token_c, 100, OrderSide::Sell);
1532
1533        let result = algorithm
1534            .find_best_route(manager.graph(), market, None, &order)
1535            .await;
1536        assert!(
1537            matches!(result, Err(AlgorithmError::NoPath { .. })),
1538            "Should return NoPath when max_hops is insufficient"
1539        );
1540    }
1541
1542    #[tokio::test]
1543    async fn test_find_best_route_timeout_returns_best_so_far() {
1544        // Setup: Many parallel paths to process
1545        // With very short timeout, should return the best route found before timeout
1546        // or Timeout error if no route was completed
1547        let token_a = token(0x01, "A");
1548        let token_b = token(0x02, "B");
1549
1550        // Create many parallel pools to ensure multiple paths need processing
1551        let (market, manager) = setup_market(vec![
1552            ("pool1", &token_a, &token_b, MockProtocolSim::new(1.0)),
1553            ("pool2", &token_a, &token_b, MockProtocolSim::new(2.0)),
1554            ("pool3", &token_a, &token_b, MockProtocolSim::new(3.0)),
1555            ("pool4", &token_a, &token_b, MockProtocolSim::new(4.0)),
1556            ("pool5", &token_a, &token_b, MockProtocolSim::new(5.0)),
1557        ]);
1558
1559        // timeout=0ms should timeout after processing some paths
1560        let algorithm = MostLiquidAlgorithm::with_config(
1561            AlgorithmConfig::new(1, 1, Duration::from_millis(0), None).unwrap(),
1562        )
1563        .unwrap();
1564        let order = order(&token_a, &token_b, 100, OrderSide::Sell);
1565
1566        let result = algorithm
1567            .find_best_route(manager.graph(), market, None, &order)
1568            .await;
1569
1570        // With 0ms timeout, we either get:
1571        // - A route (if at least one path completed before timeout check)
1572        // - Timeout error (if no path completed)
1573        // Both are valid outcomes - the key is we don't hang
1574        match result {
1575            Ok(r) => {
1576                // If we got a route, verify it's valid
1577                assert_eq!(r.route().swaps().len(), 1);
1578            }
1579            Err(AlgorithmError::Timeout { .. }) => {
1580                // Timeout is also acceptable
1581            }
1582            Err(e) => panic!("Unexpected error: {:?}", e),
1583        }
1584    }
1585
1586    // ==================== Algorithm Trait Getter Tests ====================
1587
1588    #[rstest::rstest]
1589    #[case::default_config(1, 3, 50)]
1590    #[case::single_hop_only(1, 1, 100)]
1591    #[case::multi_hop_min(2, 5, 200)]
1592    #[case::zero_timeout(1, 3, 0)]
1593    #[case::large_values(10, 100, 10000)]
1594    fn test_algorithm_config_getters(
1595        #[case] min_hops: usize,
1596        #[case] max_hops: usize,
1597        #[case] timeout_ms: u64,
1598    ) {
1599        use crate::algorithm::Algorithm;
1600
1601        let algorithm = MostLiquidAlgorithm::with_config(
1602            AlgorithmConfig::new(min_hops, max_hops, Duration::from_millis(timeout_ms), None)
1603                .unwrap(),
1604        )
1605        .unwrap();
1606
1607        assert_eq!(algorithm.max_hops, max_hops);
1608        assert_eq!(algorithm.timeout, Duration::from_millis(timeout_ms));
1609        assert_eq!(algorithm.name(), "most_liquid");
1610    }
1611
1612    #[test]
1613    fn test_algorithm_default_config() {
1614        use crate::algorithm::Algorithm;
1615
1616        let algorithm = MostLiquidAlgorithm::new();
1617
1618        assert_eq!(algorithm.max_hops, 3);
1619        assert_eq!(algorithm.timeout, Duration::from_millis(500));
1620        assert_eq!(algorithm.name(), "most_liquid");
1621    }
1622
1623    // ==================== Configuration Validation Tests ====================
1624
1625    #[tokio::test]
1626    async fn test_find_best_route_respects_max_routes_cap() {
1627        // 4 parallel pools. Score = spot_price * min_depth.
1628        // In tests, depth comes from get_limits().0 (sell_limit), which is
1629        // liquidity / (spot_price * (1 - fee)). With fee=0: depth = liquidity / spot_price.
1630        // We vary liquidity to create a clear score ranking:
1631        //   pool4 (score = 1.0 * 4M/1.0 = 4M)
1632        //   pool3 (score = 2.0 * 3M/2.0 = 3M)
1633        //   pool2 (score = 3.0 * 2M/3.0 = 2M)
1634        //   pool1 (score = 4.0 * 1M/4.0 = 1M)
1635        //
1636        // With max_routes=2, only pool4 and pool3 are simulated.
1637        // pool1 has the best simulation output (4x) but the lowest score,
1638        // so it's excluded by the cap.
1639        let token_a = token(0x01, "A");
1640        let token_b = token(0x02, "B");
1641
1642        let (market, manager) = setup_market(vec![
1643            ("pool1", &token_a, &token_b, MockProtocolSim::new(4.0).with_liquidity(1_000_000)),
1644            ("pool2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(2_000_000)),
1645            ("pool3", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(3_000_000)),
1646            ("pool4", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(4_000_000)),
1647        ]);
1648
1649        // Cap at 2: only the two highest-scored paths are simulated
1650        let algorithm = MostLiquidAlgorithm::with_config(
1651            AlgorithmConfig::new(1, 1, Duration::from_millis(100), Some(2)).unwrap(),
1652        )
1653        .unwrap();
1654        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
1655        let result = algorithm
1656            .find_best_route(manager.graph(), market, None, &order)
1657            .await
1658            .unwrap();
1659
1660        // pool1 has the best simulation output (4x) but lowest score, so it's
1661        // excluded by the cap. Among the top-2 scored (pool4=4M, pool3=3M),
1662        // pool3 gives the best simulation output (2x vs 1x).
1663        assert_eq!(result.route().swaps().len(), 1);
1664        assert_eq!(result.route().swaps()[0].component_id(), "pool3");
1665        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(2000u64));
1666    }
1667
1668    #[tokio::test]
1669    async fn test_find_best_route_no_cap_when_max_routes_is_none() {
1670        // Same setup but no cap — pool1 (best output) should win.
1671        let token_a = token(0x01, "A");
1672        let token_b = token(0x02, "B");
1673
1674        let (market, manager) = setup_market(vec![
1675            ("pool1", &token_a, &token_b, MockProtocolSim::new(4.0).with_liquidity(1_000_000)),
1676            ("pool2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(2_000_000)),
1677            ("pool3", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(3_000_000)),
1678            ("pool4", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(4_000_000)),
1679        ]);
1680
1681        let algorithm = MostLiquidAlgorithm::with_config(
1682            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1683        )
1684        .unwrap();
1685        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
1686        let result = algorithm
1687            .find_best_route(manager.graph(), market, None, &order)
1688            .await
1689            .unwrap();
1690
1691        // All 4 paths simulated, pool1 wins with best output (4x)
1692        assert_eq!(result.route().swaps().len(), 1);
1693        assert_eq!(result.route().swaps()[0].component_id(), "pool1");
1694        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(4000u64));
1695    }
1696
1697    #[test]
1698    fn test_algorithm_config_rejects_zero_max_routes() {
1699        let result = AlgorithmConfig::new(1, 3, Duration::from_millis(100), Some(0));
1700        assert!(matches!(
1701            result,
1702            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("max_routes must be at least 1")
1703        ));
1704    }
1705
1706    #[test]
1707    fn test_algorithm_config_rejects_zero_min_hops() {
1708        let result = AlgorithmConfig::new(0, 3, Duration::from_millis(100), None);
1709        assert!(matches!(
1710            result,
1711            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("min_hops must be at least 1")
1712        ));
1713    }
1714
1715    #[test]
1716    fn test_algorithm_config_rejects_min_greater_than_max() {
1717        let result = AlgorithmConfig::new(5, 3, Duration::from_millis(100), None);
1718        assert!(matches!(
1719            result,
1720            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("cannot exceed")
1721        ));
1722    }
1723}