Skip to main content

fynd_core/algorithm/
most_liquid.rs

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