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