Skip to main content

fynd_core/algorithm/
mod.rs

1//! Route-finding algorithms.
2//!
3//! This module defines the Algorithm trait and built-in implementations.
4//! New algorithms can be added by implementing the trait.
5//!
6//! Algorithms are generic over their preferred graph type, allowing them to use
7//! different graph crates (petgraph, custom, etc.) and leverage built-in algorithms.
8//!
9//! # Adding a New Algorithm
10//!
11//! **External:** Implement the `Algorithm` trait in your own crate and plug it
12//! into a [`WorkerPoolBuilder`](crate::worker_pool::pool::WorkerPoolBuilder) via
13//! [`with_algorithm`](crate::worker_pool::pool::WorkerPoolBuilder::with_algorithm). No changes
14//! to fynd-core required. See the `custom_algorithm` example.
15//!
16//! **Built-in:** To add an algorithm to the built-in registry:
17//! 1. Create a new module with your algorithm implementation
18//! 2. Implement the `Algorithm` trait
19//! 3. Register it in `registry.rs`
20
21pub mod bellman_ford;
22pub mod most_liquid;
23
24#[cfg(test)]
25pub mod test_utils;
26
27use std::time::Duration;
28
29pub use bellman_ford::BellmanFordAlgorithm;
30pub use most_liquid::MostLiquidAlgorithm;
31use tycho_simulation::tycho_core::models::Address;
32
33use crate::{
34    derived::{computation::ComputationRequirements, SharedDerivedDataRef},
35    feed::market_data::SharedMarketDataRef,
36    graph::GraphManager,
37    types::{quote::Order, RouteResult},
38};
39
40/// Configuration for an Algorithm instance.
41#[must_use]
42#[derive(Debug, Clone)]
43pub struct AlgorithmConfig {
44    /// Minimum hops to search (must be >= 1).
45    min_hops: usize,
46    /// Maximum hops to search.
47    max_hops: usize,
48    /// Timeout for solving.
49    timeout: Duration,
50    /// Maximum number of paths to simulate. `None` means no cap.
51    max_routes: Option<usize>,
52    /// Enable gas-aware comparison (compares net amounts instead of gross during path selection).
53    /// Currently used by Bellman-Ford; ignored by other algorithms. Defaults to true.
54    gas_aware: bool,
55}
56
57impl AlgorithmConfig {
58    /// Creates a new `AlgorithmConfig` with validation.
59    ///
60    /// # Errors
61    ///
62    /// Returns `InvalidConfiguration` if:
63    /// - `min_hops == 0` (at least one hop is required)
64    /// - `min_hops > max_hops`
65    /// - `max_routes` is `Some(0)`
66    pub fn new(
67        min_hops: usize,
68        max_hops: usize,
69        timeout: Duration,
70        max_routes: Option<usize>,
71    ) -> Result<Self, AlgorithmError> {
72        if min_hops == 0 {
73            return Err(AlgorithmError::InvalidConfiguration {
74                reason: "min_hops must be at least 1".to_string(),
75            });
76        }
77        if min_hops > max_hops {
78            return Err(AlgorithmError::InvalidConfiguration {
79                reason: format!("min_hops ({}) cannot exceed max_hops ({})", min_hops, max_hops),
80            });
81        }
82        if max_routes == Some(0) {
83            return Err(AlgorithmError::InvalidConfiguration {
84                reason: "max_routes must be at least 1".to_string(),
85            });
86        }
87        Ok(Self { min_hops, max_hops, timeout, max_routes, gas_aware: true })
88    }
89
90    /// Returns the minimum number of hops to search.
91    pub fn min_hops(&self) -> usize {
92        self.min_hops
93    }
94
95    /// Returns the maximum number of hops to search.
96    pub fn max_hops(&self) -> usize {
97        self.max_hops
98    }
99
100    /// Returns the maximum number of paths to simulate.
101    pub fn max_routes(&self) -> Option<usize> {
102        self.max_routes
103    }
104
105    /// Returns the timeout for solving.
106    pub fn timeout(&self) -> Duration {
107        self.timeout
108    }
109
110    /// Returns whether gas-aware comparison is enabled.
111    pub fn gas_aware(&self) -> bool {
112        self.gas_aware
113    }
114
115    /// Sets gas-aware comparison.
116    pub fn with_gas_aware(mut self, enabled: bool) -> Self {
117        self.gas_aware = enabled;
118        self
119    }
120}
121
122impl Default for AlgorithmConfig {
123    fn default() -> Self {
124        // Default values are valid, so we can unwrap safely
125        Self::new(1, 3, Duration::from_millis(100), None).unwrap()
126    }
127}
128
129/// Trait for route-finding algorithms.
130///
131/// Algorithms are generic over their preferred graph type `G`, allowing them to:
132/// - Use different graph crates (petgraph, custom, etc.)
133/// - Leverage built-in algorithms from graph libraries
134/// - Optimize their graph representation for their specific needs
135///
136/// # Implementation Notes
137///
138/// - Algorithms should respect the timeout from `timeout()`
139/// - They should use `graph` for path finding (BFS/etc)
140/// - They should use `market` to read component states for simulation
141/// - They should NOT modify the graph or market data
142#[allow(async_fn_in_trait)]
143pub trait Algorithm: Send + Sync {
144    /// The graph type this algorithm uses.
145    type GraphType: Send + Sync;
146
147    /// The graph manager type for this algorithm.
148    /// This allows the solver to automatically create the appropriate graph manager.
149    type GraphManager: GraphManager<Self::GraphType> + Default;
150
151    /// Returns the algorithm's name.
152    fn name(&self) -> &str;
153
154    /// Finds the best route for the given order.
155    ///
156    /// # Arguments
157    ///
158    /// * `graph` - The algorithm's preferred graph type (e.g., petgraph::Graph)
159    /// * `market` - Shared reference to market data for state lookups (algorithms acquire their own
160    ///   locks)
161    /// * `derived` - Optional shared reference to derived data (token prices, etc.)
162    /// * `order` - The order to solve
163    ///
164    /// # Returns
165    ///
166    /// The best route and its gas-adjusted net output amount, or an error if no route could be
167    /// found.
168    async fn find_best_route(
169        &self,
170        graph: &Self::GraphType,
171        market: SharedMarketDataRef,
172        derived: Option<SharedDerivedDataRef>,
173        order: &Order,
174    ) -> Result<RouteResult, AlgorithmError>;
175
176    /// Returns the derived data computation requirements for this algorithm.
177    ///
178    /// Algorithms declare freshness requirements for derived data:
179    /// - `require_fresh`: Data must be from the current block (same as SharedMarketData)
180    /// - `allow_stale`: Data can be from any past block, as long as it exists
181    ///
182    /// Workers use this to determine when they can safely solve.
183    ///
184    /// Default implementation returns no requirements - algorithm works without
185    /// any derived data.
186    fn computation_requirements(&self) -> ComputationRequirements;
187
188    /// Returns the timeout for solving.
189    ///
190    /// Workers use this to set the maximum time to wait for derived data
191    /// before failing a solve request.
192    fn timeout(&self) -> Duration;
193}
194
195/// Errors that can occur during route finding.
196#[non_exhaustive]
197#[derive(Debug, Clone, thiserror::Error, PartialEq)]
198pub enum AlgorithmError {
199    /// Invalid algorithm configuration (programmer error).
200    #[non_exhaustive]
201    #[error("invalid configuration: {reason}")]
202    InvalidConfiguration {
203        /// Human-readable description of the invalid configuration.
204        reason: String,
205    },
206
207    /// No path exists between the tokens.
208    #[non_exhaustive]
209    #[error("no path from {from:?} to {to:?}: {reason}")]
210    NoPath {
211        /// Input token address.
212        from: Address,
213        /// Output token address.
214        to: Address,
215        /// Detailed reason why no path was found.
216        reason: NoPathReason,
217    },
218
219    /// Paths exist but none have sufficient liquidity.
220    #[error("insufficient liquidity on all paths")]
221    InsufficientLiquidity,
222
223    /// Route finding timed out.
224    #[non_exhaustive]
225    #[error("timeout after {elapsed_ms}ms")]
226    Timeout {
227        /// Elapsed time in milliseconds when the timeout fired.
228        elapsed_ms: u64,
229    },
230
231    /// Exact-out not supported by this algorithm.
232    #[error("exact-out orders not supported")]
233    ExactOutNotSupported,
234
235    /// Simulation failed for a specific component.
236    #[non_exhaustive]
237    #[error("simulation failed for {component_id}: {error}")]
238    SimulationFailed {
239        /// ID of the pool component that failed.
240        component_id: String,
241        /// Underlying simulation error message.
242        error: String,
243    },
244
245    /// Required data not found in market.
246    #[non_exhaustive]
247    #[error("{kind} not found{}", id.as_ref().map(|i| format!(": {i}")).unwrap_or_default())]
248    DataNotFound {
249        /// Category of the missing data (e.g. `"token"`, `"component"`).
250        kind: &'static str,
251        /// Optional identifier of the missing item.
252        id: Option<String>,
253    },
254
255    /// Other algorithm-specific error.
256    #[error("{0}")]
257    Other(String),
258}
259
260/// Reason why no path was found between tokens.
261#[non_exhaustive]
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum NoPathReason {
264    /// Source token not present in the routing graph.
265    SourceTokenNotInGraph,
266    /// Destination token not present in the routing graph.
267    DestinationTokenNotInGraph,
268    /// Both tokens exist but no edges connect them within hop limits.
269    NoGraphPath,
270    /// Paths exist but none could be scored (e.g., missing edge weights).
271    NoScorablePaths,
272}
273
274impl std::fmt::Display for NoPathReason {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        match self {
277            Self::SourceTokenNotInGraph => write!(f, "source token not in graph"),
278            Self::DestinationTokenNotInGraph => write!(f, "destination token not in graph"),
279            Self::NoGraphPath => write!(f, "no connecting path in graph"),
280            Self::NoScorablePaths => write!(f, "no paths with valid scores"),
281        }
282    }
283}