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