From 72e54d96b26be12d7081e35e36a73cd8638495a1 Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 16 Mar 2026 15:13:22 -0600 Subject: [PATCH 1/4] Replace DFS market price with Bellman-Ford SPFA one-to-all pricing Add bellman_ford_pricing.rs with flat-array SPFA that prices all tokens from gas_token in a single traversal, using simulation during relaxation instead of the DFS + spot-price heuristic approach. Rewrite token_gas_price.rs to use BF forward pass + reverse simulation per token. Removes SpotPrices dependency entirely. Selects paths by highest forward output (most liquid) rather than lowest spread. 12/12 tests pass. No regressions in full suite. Co-Authored-By: Claude Opus 4.6 --- .../src/algorithm/bellman_ford_pricing.rs | 593 ++++++++++++++ fynd-core/src/algorithm/mod.rs | 1 + .../derived/computations/token_gas_price.rs | 722 ++++++------------ fynd-core/src/graph/mod.rs | 1 + 4 files changed, 819 insertions(+), 498 deletions(-) create mode 100644 fynd-core/src/algorithm/bellman_ford_pricing.rs diff --git a/fynd-core/src/algorithm/bellman_ford_pricing.rs b/fynd-core/src/algorithm/bellman_ford_pricing.rs new file mode 100644 index 00000000..019f8726 --- /dev/null +++ b/fynd-core/src/algorithm/bellman_ford_pricing.rs @@ -0,0 +1,593 @@ +//! Bellman-Ford SPFA for one-to-all token pricing. +//! +//! Runs a single flat-array SPFA from a source token (e.g. WETH) with a probe amount, +//! propagating to all reachable tokens within `max_hops`. Uses forbid-revisits to prevent +//! paths through arbitrage loops that would distort prices. +//! +//! This is the pricing counterpart to the routing BF in `bellman_ford.rs`: +//! - Routing: one source, one destination, real trade amount +//! - Pricing: one source, ALL destinations, small probe amount + +use std::collections::{HashMap, HashSet, VecDeque}; + +use num_bigint::BigUint; +use num_traits::Zero; +use petgraph::{graph::NodeIndex, prelude::EdgeRef}; +use tracing::trace; +use tycho_simulation::{ + evm::{engine_db::tycho_db::PreCachedDB, protocol::vm::state::EVMPoolState}, + tycho_common::simulation::protocol_sim::ProtocolSim, + tycho_core::models::token::Token, +}; + +use super::AlgorithmError; +use crate::{ + feed::market_data::SharedMarketData, + graph::petgraph::StableDiGraph, + types::{ComponentId, Route, Swap}, +}; + +/// Result of a one-to-all SPFA run. Contains distances and predecessors for all +/// reachable tokens from the source. +pub(crate) struct SpfaAllResult { + distance: Vec, + predecessor: Vec>, + source: NodeIndex, + token_map: HashMap, +} + +impl SpfaAllResult { + /// Returns the best forward amount reachable at `node`. + #[allow(dead_code)] + pub fn amount_at(&self, node: NodeIndex) -> &BigUint { + &self.distance[node.index()] + } + + /// Returns true if `node` was reached by the SPFA. + pub fn is_reachable(&self, node: NodeIndex) -> bool { + !self.distance[node.index()].is_zero() + } + + /// Reconstructs the path from source to `dest`. + pub fn reconstruct_path( + &self, + dest: NodeIndex, + ) -> Result, AlgorithmError> { + reconstruct_path(dest, self.source, &self.predecessor) + } + + /// Returns a reference to the token map built during subgraph extraction. + pub fn token_map(&self) -> &HashMap { + &self.token_map + } +} + +/// Runs a flat Bellman-Ford SPFA from `source` with `amount`, propagating to all +/// reachable tokens within `max_hops`. Uses forbid-revisits to prevent paths +/// through arbitrage loops. +/// +/// One forward pass prices every token reachable from the source. +pub(crate) fn solve_one_to_all( + source: NodeIndex, + amount: BigUint, + max_hops: usize, + graph: &StableDiGraph<()>, + market: &SharedMarketData, +) -> SpfaAllResult { + // Extract subgraph (BFS from source up to max_hops) + let subgraph_edges = extract_subgraph(source, max_hops, graph); + + let max_idx = graph + .node_indices() + .map(|n| n.index()) + .max() + .unwrap_or(0) + + 1; + + if subgraph_edges.is_empty() { + return SpfaAllResult { + distance: vec![BigUint::ZERO; max_idx], + predecessor: vec![None; max_idx], + source, + token_map: HashMap::new(), + }; + } + + // Build token map for all nodes in subgraph + let subgraph_nodes: HashSet = subgraph_edges + .iter() + .flat_map(|&(from, to, _)| [from, to]) + .collect(); + + let token_map: HashMap = subgraph_nodes + .iter() + .filter_map(|&node| { + let addr = &graph[node]; + market + .get_token(addr) + .cloned() + .map(|t| (node, t)) + }) + .collect(); + + let mut distance: Vec = vec![BigUint::ZERO; max_idx]; + let mut predecessor: Vec> = vec![None; max_idx]; + + distance[source.index()] = amount; + + // Build adjacency list + let mut adj: HashMap> = HashMap::new(); + for (from, to, cid) in &subgraph_edges { + adj.entry(*from) + .or_default() + .push((*to, cid)); + } + + // SPFA: seed active set with source node + let mut active_nodes: Vec = vec![source]; + + for _round in 0..max_hops { + if active_nodes.is_empty() { + break; + } + + let mut next_active: HashSet = HashSet::new(); + + for &u in &active_nodes { + let u_idx = u.index(); + if distance[u_idx].is_zero() { + continue; + } + + let Some(token_u) = token_map.get(&u) else { + continue; + }; + + let Some(edges) = adj.get(&u) else { + continue; + }; + + for &(v, component_id) in edges { + let v_idx = v.index(); + + // Forbid token revisits + if path_contains_node(u, v, &predecessor) { + continue; + } + + // Forbid pool revisits + if path_contains_pool(u, component_id, &predecessor) { + continue; + } + + let Some(token_v) = token_map.get(&v) else { + continue; + }; + + let Some(sim_state) = market.get_simulation_state(component_id) else { + continue; + }; + + let result = + match sim_state.get_amount_out(distance[u_idx].clone(), token_u, token_v) { + Ok(r) => r, + Err(e) => { + trace!( + component_id, + error = %e, + "get_amount_out failed during relaxation, skipping edge" + ); + continue; + } + }; + + let amount_out = result.amount; + + if amount_out > distance[v_idx] { + distance[v_idx] = amount_out; + predecessor[v_idx] = Some((u, component_id.clone())); + next_active.insert(v); + } + } + } + + active_nodes = next_active.into_iter().collect(); + } + + SpfaAllResult { distance, predecessor, source, token_map } +} + +/// Re-simulates a path with exact amounts and state overrides for revisited pools. +pub(crate) fn resimulate_path( + path: &[(NodeIndex, NodeIndex, ComponentId)], + amount_in: &BigUint, + market: &SharedMarketData, + token_map: &HashMap, +) -> Result<(Route, BigUint), AlgorithmError> { + let mut current_amount = amount_in.clone(); + let mut swaps = Vec::with_capacity(path.len()); + + let mut native_state_overrides: HashMap<&ComponentId, Box> = HashMap::new(); + let mut vm_state_override: Option> = None; + + for (from_node, to_node, component_id) in path { + let token_in = token_map + .get(from_node) + .ok_or_else(|| AlgorithmError::DataNotFound { + kind: "token", + id: Some(format!("{:?}", from_node)), + })?; + let token_out = token_map + .get(to_node) + .ok_or_else(|| AlgorithmError::DataNotFound { + kind: "token", + id: Some(format!("{:?}", to_node)), + })?; + + let component = market + .get_component(component_id) + .ok_or_else(|| AlgorithmError::DataNotFound { + kind: "component", + id: Some(component_id.clone()), + })?; + let component_state = market + .get_simulation_state(component_id) + .ok_or_else(|| AlgorithmError::DataNotFound { + kind: "simulation state", + id: Some(component_id.clone()), + })?; + + let is_vm = component_state + .as_any() + .downcast_ref::>() + .is_some(); + + let state_override = if is_vm { + vm_state_override.as_ref() + } else { + native_state_overrides.get(component_id) + }; + + let state = state_override + .map(Box::as_ref) + .unwrap_or(component_state); + + let result = state + .get_amount_out(current_amount.clone(), token_in, token_out) + .map_err(|e| AlgorithmError::SimulationFailed { + component_id: component_id.clone(), + error: format!("{:?}", e), + })?; + + swaps.push(Swap { + component_id: component_id.clone(), + protocol: component.protocol_system.clone(), + token_in: token_in.address.clone(), + token_out: token_out.address.clone(), + amount_in: current_amount.clone(), + amount_out: result.amount.clone(), + gas_estimate: result.gas, + }); + + if is_vm { + vm_state_override = Some(result.new_state); + } else { + native_state_overrides.insert(component_id, result.new_state); + } + current_amount = result.amount; + } + + let route = Route::new(swaps); + Ok((route, current_amount)) +} + +// --- Private helpers --- + +/// Extracts a subgraph via BFS from `start` up to `max_depth` hops. +fn extract_subgraph( + start: NodeIndex, + max_depth: usize, + graph: &StableDiGraph<()>, +) -> Vec<(NodeIndex, NodeIndex, ComponentId)> { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + let mut edges = Vec::new(); + + visited.insert(start); + queue.push_back((start, 0usize)); + + while let Some((node, depth)) = queue.pop_front() { + if depth >= max_depth { + continue; + } + + for edge in graph.edges(node) { + let target = edge.target(); + let component_id = &edge.weight().component_id; + + edges.push((node, target, component_id.clone())); + + if !visited.contains(&target) { + visited.insert(target); + queue.push_back((target, depth + 1)); + } + } + } + + edges +} + +/// Checks whether `target` node is already in the predecessor path leading to `from`. +fn path_contains_node( + from: NodeIndex, + target: NodeIndex, + predecessor: &[Option<(NodeIndex, ComponentId)>], +) -> bool { + let mut current = from; + loop { + if current == target { + return true; + } + match &predecessor[current.index()] { + Some((prev, _)) => current = *prev, + None => return false, + } + } +} + +/// Checks whether `target_pool` is already used in the predecessor path leading to `from`. +fn path_contains_pool( + from: NodeIndex, + target_pool: &ComponentId, + predecessor: &[Option<(NodeIndex, ComponentId)>], +) -> bool { + let mut current = from; + loop { + match &predecessor[current.index()] { + Some((prev, cid)) => { + if cid == target_pool { + return true; + } + current = *prev; + } + None => return false, + } + } +} + +/// Reconstructs the path from dest back to source by walking the flat predecessor array. +fn reconstruct_path( + dest: NodeIndex, + source: NodeIndex, + predecessor: &[Option<(NodeIndex, ComponentId)>], +) -> Result, AlgorithmError> { + let mut path = Vec::new(); + let mut current = dest; + let mut visited = HashSet::new(); + + while current != source { + if !visited.insert(current) { + return Err(AlgorithmError::Other( + "cycle in predecessor chain during path reconstruction".to_string(), + )); + } + + let idx = current.index(); + if idx >= predecessor.len() { + return Err(AlgorithmError::Other("predecessor index out of bounds".to_string())); + } + + match &predecessor[idx] { + Some((prev_node, component_id)) => { + path.push((*prev_node, current, component_id.clone())); + current = *prev_node; + } + None => { + return Err(AlgorithmError::Other(format!( + "broken predecessor chain at node {idx}" + ))); + } + } + } + + path.reverse(); + Ok(path) +} + +#[cfg(test)] +mod tests { + use tycho_simulation::tycho_core::models::token::Token; + + use super::*; + use crate::algorithm::test_utils::{component, token, MockProtocolSim}; + use crate::graph::{GraphManager, PetgraphStableDiGraphManager}; + + fn setup_market_and_graph( + pools: Vec<(&str, &Token, &Token, MockProtocolSim)>, + ) -> (SharedMarketData, PetgraphStableDiGraphManager<()>) { + let mut market = SharedMarketData::new(); + + for (pool_id, token_in, token_out, state) in pools { + let tokens = vec![token_in.clone(), token_out.clone()]; + let comp = component(pool_id, &tokens); + market.upsert_components(std::iter::once(comp)); + market.update_states([(pool_id.to_string(), Box::new(state) as Box)]); + market.upsert_tokens(tokens); + } + + let mut graph_manager = PetgraphStableDiGraphManager::default(); + graph_manager.initialize_graph(&market.component_topology()); + + (market, graph_manager) + } + + #[test] + fn solve_one_to_all_prices_all_reachable_tokens() { + let eth = token(0, "ETH"); + let usdc = token(1, "USDC"); + let dai = token(2, "DAI"); + + let (market, gm) = setup_market_and_graph(vec![ + ("eth_usdc", ð, &usdc, MockProtocolSim::new(2000)), + ("usdc_dai", &usdc, &dai, MockProtocolSim::new(1)), + ]); + + let graph = gm.graph(); + let eth_node = graph + .node_indices() + .find(|&n| graph[n] == eth.address) + .unwrap(); + + let result = solve_one_to_all(eth_node, BigUint::from(100u64), 2, graph, &market); + + // ETH -> USDC: 100 * 2000 = 200_000 + let usdc_node = graph + .node_indices() + .find(|&n| graph[n] == usdc.address) + .unwrap(); + assert!(result.is_reachable(usdc_node)); + assert_eq!(*result.amount_at(usdc_node), BigUint::from(200_000u64)); + + // ETH -> USDC -> DAI: 200_000 * 1 = 200_000 + let dai_node = graph + .node_indices() + .find(|&n| graph[n] == dai.address) + .unwrap(); + assert!(result.is_reachable(dai_node)); + assert_eq!(*result.amount_at(dai_node), BigUint::from(200_000u64)); + } + + #[test] + fn solve_one_to_all_picks_best_path() { + // Diamond: ETH->A->TARGET (2*3=6x) vs ETH->B->TARGET (4*1=4x) + let eth = token(0, "ETH"); + let a = token(1, "A"); + let b = token(2, "B"); + let target = token(3, "TARGET"); + + let (market, gm) = setup_market_and_graph(vec![ + ("eth_a", ð, &a, MockProtocolSim::new(2)), + ("a_target", &a, &target, MockProtocolSim::new(3)), + ("eth_b", ð, &b, MockProtocolSim::new(4)), + ("b_target", &b, &target, MockProtocolSim::new(1)), + ]); + + let graph = gm.graph(); + let eth_node = graph + .node_indices() + .find(|&n| graph[n] == eth.address) + .unwrap(); + + let result = solve_one_to_all(eth_node, BigUint::from(100u64), 2, graph, &market); + + let target_node = graph + .node_indices() + .find(|&n| graph[n] == target.address) + .unwrap(); + + // Best path: ETH->A->TARGET = 100*2*3 = 600 + assert_eq!(*result.amount_at(target_node), BigUint::from(600u64)); + } + + #[test] + fn solve_one_to_all_respects_max_hops() { + let eth = token(0, "ETH"); + let a = token(1, "A"); + let b = token(2, "B"); + let c = token(3, "C"); + + let (market, gm) = setup_market_and_graph(vec![ + ("eth_a", ð, &a, MockProtocolSim::new(2)), + ("a_b", &a, &b, MockProtocolSim::new(3)), + ("b_c", &b, &c, MockProtocolSim::new(4)), + ]); + + let graph = gm.graph(); + let eth_node = graph + .node_indices() + .find(|&n| graph[n] == eth.address) + .unwrap(); + + // max_hops=2: can reach A (1 hop) and B (2 hops), but NOT C (3 hops) + let result = solve_one_to_all(eth_node, BigUint::from(100u64), 2, graph, &market); + + let c_node = graph + .node_indices() + .find(|&n| graph[n] == c.address) + .unwrap(); + assert!(!result.is_reachable(c_node), "C should not be reachable with max_hops=2"); + } + + #[test] + fn reconstruct_and_resimulate_round_trip() { + let eth = token(0, "ETH"); + let usdc = token(1, "USDC"); + + let (market, gm) = + setup_market_and_graph(vec![("pool", ð, &usdc, MockProtocolSim::new(2000))]); + + let graph = gm.graph(); + let eth_node = graph + .node_indices() + .find(|&n| graph[n] == eth.address) + .unwrap(); + let usdc_node = graph + .node_indices() + .find(|&n| graph[n] == usdc.address) + .unwrap(); + + let result = solve_one_to_all(eth_node, BigUint::from(100u64), 2, graph, &market); + + // Reconstruct path + let path = result + .reconstruct_path(usdc_node) + .unwrap(); + assert_eq!(path.len(), 1); + assert_eq!(path[0].2, "pool"); + + // Re-simulate + let (route, amount_out) = + resimulate_path(&path, &BigUint::from(100u64), &market, result.token_map()).unwrap(); + assert_eq!(route.swaps.len(), 1); + assert_eq!(amount_out, BigUint::from(200_000u64)); + } + + #[test] + fn forbid_revisits_prevents_cycles() { + // ETH -> A -> ETH would be a token revisit; should be forbidden + let eth = token(0, "ETH"); + let a = token(1, "A"); + let b = token(2, "B"); + + let (market, gm) = setup_market_and_graph(vec![ + ("eth_a", ð, &a, MockProtocolSim::new(2)), + ("a_b", &a, &b, MockProtocolSim::new(3)), + ]); + + let graph = gm.graph(); + let eth_node = graph + .node_indices() + .find(|&n| graph[n] == eth.address) + .unwrap(); + + // With forbid-revisits, ETH should not appear as reachable + // (it's the source, distance is set to probe amount, not a revisit result) + let result = solve_one_to_all(eth_node, BigUint::from(100u64), 4, graph, &market); + + // A should be reachable (1 hop) + let a_node = graph + .node_indices() + .find(|&n| graph[n] == a.address) + .unwrap(); + assert!(result.is_reachable(a_node)); + assert_eq!(*result.amount_at(a_node), BigUint::from(200u64)); + + // B should be reachable (2 hops) + let b_node = graph + .node_indices() + .find(|&n| graph[n] == b.address) + .unwrap(); + assert!(result.is_reachable(b_node)); + assert_eq!(*result.amount_at(b_node), BigUint::from(600u64)); + } +} diff --git a/fynd-core/src/algorithm/mod.rs b/fynd-core/src/algorithm/mod.rs index 774cdd18..89b0290b 100644 --- a/fynd-core/src/algorithm/mod.rs +++ b/fynd-core/src/algorithm/mod.rs @@ -19,6 +19,7 @@ //! 3. Register it in `registry.rs` pub mod bellman_ford; +pub mod bellman_ford_pricing; pub mod most_liquid; #[cfg(test)] diff --git a/fynd-core/src/derived/computations/token_gas_price.rs b/fynd-core/src/derived/computations/token_gas_price.rs index 932a230c..d074eae2 100644 --- a/fynd-core/src/derived/computations/token_gas_price.rs +++ b/fynd-core/src/derived/computations/token_gas_price.rs @@ -1,71 +1,55 @@ -//! Computes the `mid_price` of tokens relative to a gas token (e.g., ETH), selecting paths -//! by the lowest spread (the most reliable price) derived from full simulation of both buy and sell -//! directions. +//! Computes the `mid_price` of tokens relative to a gas token (e.g., ETH), using +//! Bellman-Ford SPFA to find the optimal path per token and full simulation of both +//! buy and sell directions to derive spread and mid-price. //! //! # Algorithm //! -//! 1. **Path Discovery (DFS)**: Enumerate all paths from gas_token to each reachable token, scoring -//! by spot-price spread: `|forward_spot - 1/reverse_spot|`. Lower spread = better score. +//! 1. **BF Forward Pass (one-to-all)**: Run SPFA from gas_token with a probe amount. +//! Each token's distance = the best amount reachable via simulation during relaxation. +//! This replaces DFS path enumeration AND spot-price scoring in a single pass. //! -//! 2. **Sort**: Order paths per token by spread score (lowest spread first). -//! -//! 3. **Round-Robin Simulation**: For each token, simulate paths in ranked order and compute their -//! spread and mid_price by simulating both directions on the same path. Pick the path with the -//! tightest spread for each token, as this indicates the most reliable/liquid route, and provide -//! its mid_price as the token's price. +//! 2. **Reverse Simulation**: For each priced token, reverse the winning path and simulate +//! the sell direction. Compute spread and mid_price from forward + reverse amounts. //! //! # Price Formulas //! //! For a path P from gas_token to target: -//! - `buy_out` = simulate(P, probe_amount) → tokens received -//! - `sell_out` = simulate(reverse(P), buy_out) → gas_token received back +//! - `buy_out` = simulate(P, probe_amount) -> tokens received +//! - `sell_out` = simulate(reverse(P), buy_out) -> gas_token received back //! - `buy_price` = buy_out / (probe_amount + gas_cost) //! - `sell_price` = buy_out / (sell_out - gas_cost) //! - `mid_price` = (buy_price + sell_price) / 2 //! - `spread` = |sell_price - buy_price| -//! -//! # Dependencies -//! -//! This computation depends on [`SpotPrices`](crate::derived::types::SpotPrices) being -//! available in the [`DerivedDataStore`](crate::derived::store::DerivedDataStore). -//! Ensure `SpotPriceComputation` runs before this computation. use std::collections::{HashMap, HashSet}; use async_trait::async_trait; use num_bigint::BigUint; use num_traits::ToPrimitive; -use petgraph::{graph::NodeIndex, prelude::EdgeRef}; +use petgraph::graph::NodeIndex; use tracing::{debug, instrument, trace, Span}; use tycho_simulation::{ tycho_common::models::Address, tycho_core::simulation::protocol_sim::Price, }; use crate::{ + algorithm::bellman_ford_pricing::{resimulate_path, solve_one_to_all, SpfaAllResult}, derived::{ computation::{ComputationId, DerivedComputation}, error::ComputationError, manager::{ChangedComponents, SharedDerivedDataRef}, - types::{SpotPriceKey, SpotPrices, TokenGasPrices, TokenPriceEntry, TokenPricesWithDeps}, + types::{TokenGasPrices, TokenPriceEntry, TokenPricesWithDeps}, }, feed::market_data::{SharedMarketData, SharedMarketDataRef}, - graph::{GraphManager, Path, PetgraphStableDiGraphManager}, + graph::{GraphManager, PetgraphStableDiGraphManager}, types::ComponentId, - MostLiquidAlgorithm, }; -/// A path with its score -#[derive(Clone)] -struct CandidatePath<'a> { - path: Path<'a, ()>, - score: f64, -} - -/// Computes token prices relative to the gas token. Returns the buy price for the path -/// with the lowest spread (most reliable) that we managed to find. +/// Computes token prices relative to the gas token using Bellman-Ford SPFA. /// -/// Uses DFS to discover paths, spot prices for ranking, and full simulation -/// for accurate output amounts and spread calculation. +/// Runs a single BF forward pass to find the optimal path per token, then +/// simulates the reverse direction on each winning path to compute spread +/// and mid-price. #[derive(Debug, Clone)] pub struct TokenGasPriceComputation { /// The gas token address (e.g., ETH). @@ -102,99 +86,11 @@ impl TokenGasPriceComputation { Self { gas_token, ..self } } - /// DFS to discover all paths from gas_token, scored by spot-price spread. - fn discover_paths<'a>( - &self, - graph_manager: &'a PetgraphStableDiGraphManager<()>, - spot_prices: &SpotPrices, - ) -> Result>>, ComputationError> { - let graph = graph_manager.graph(); - - // If gas token has no pools, it won't be in the graph → no paths to discover - let Ok(entry_node) = graph_manager.find_node(&self.gas_token) else { - return Ok(HashMap::new()); - }; - - let mut paths_by_token: HashMap> = HashMap::new(); - - // DFS state - struct DfsFrame<'a> { - token_node: NodeIndex, - path: Path<'a, ()>, - forward_spot: f64, - reverse_spot: f64, - } - - let mut stack = vec![DfsFrame { - token_node: entry_node, - path: Path::new(), - forward_spot: 1.0, - reverse_spot: 1.0, - }]; - - while let Some(frame) = stack.pop() { - // Token that we reached in this frame - let token_reached = &graph[frame.token_node]; - - // Record non-empty paths (skip the starting node's empty path) - if !frame.path.is_empty() { - // Compute spread from spot prices: - // buy_price = forward_spot (target per gas when buying) - // sell_price = 1/reverse_spot (target per gas when selling) - // spread = |buy_price - sell_price| - // Score = spread directly (lower = better, 0 for symmetric pools) - let buy_price = frame.forward_spot; - let sell_price = 1.0 / frame.reverse_spot; - let spot_spread = (buy_price - sell_price).abs(); - - paths_by_token - .entry(token_reached.clone()) - .or_default() - .push(CandidatePath { path: frame.path.clone(), score: spot_spread }); - } - - // Stop exploring further if max depth reached - if frame.path.len() >= self.max_hops { - continue; - } - - // Explore neighbors - for edge in graph.edges(frame.token_node) { - let next_node = edge.target(); - let next_token = &graph[next_node]; - - let mut new_path = frame.path.clone(); - new_path.add_hop(token_reached, edge.weight(), next_token); - - let component_id = edge.weight().component_id.clone(); - - // Look up spot prices for this edge - let fwd_key: SpotPriceKey = - (component_id.clone(), token_reached.clone(), next_token.clone()); - let rev_key: SpotPriceKey = - (component_id.clone(), next_token.clone(), token_reached.clone()); - - // Skip edges with missing spot prices (pool may have failed spot price computation) - let Some(&fwd_spot) = spot_prices.get(&fwd_key) else { - continue; - }; - let Some(&rev_spot) = spot_prices.get(&rev_key) else { - continue; - }; - - stack.push(DfsFrame { - token_node: next_node, - path: new_path, - forward_spot: frame.forward_spot * fwd_spot, - reverse_spot: frame.reverse_spot * rev_spot, - }); - } - } - - Ok(paths_by_token) + pub fn simulation_amount(&self) -> &BigUint { + &self.simulation_amount } - /// Compute the spread and mid_price for a given path by simulating both directions. + /// Computes spread and mid_price for a given BF path by simulating both directions. /// /// Returns (spread_ratio, mid_price, path_components) where: /// - spread_ratio: |sell - buy|, lower = more reliable @@ -202,53 +98,39 @@ impl TokenGasPriceComputation { /// - path_components: component IDs used in this path (for incremental invalidation) fn compute_spread_and_mid_price( &self, - path: Path<()>, + forward_path: &[(NodeIndex, NodeIndex, ComponentId)], market: &SharedMarketData, gas_price: &BigUint, + spfa_result: &SpfaAllResult, ) -> Result<(f64, Price, HashSet), ComputationError> { - // Extract component IDs from path edges for dependency tracking - let path_components: HashSet = path - .edge_data + let path_components: HashSet = forward_path + .iter() + .map(|(_, _, cid)| cid.clone()) + .collect(); + + let token_map = spfa_result.token_map(); + + // Forward: gas_token -> target_token + let (forward_route, buy_out) = + resimulate_path(forward_path, &self.simulation_amount, market, token_map).map_err( + |e| ComputationError::SimulationFailed(format!("buy simulation failed: {}", e)), + )?; + let buy_gas_cost = forward_route.total_gas() * gas_price; + + // Reverse: target_token -> gas_token + let reversed_path: Vec<_> = forward_path .iter() - .map(|edge| edge.component_id.clone()) + .rev() + .map(|(from, to, cid)| (*to, *from, cid.clone())) .collect(); - // Forward: gas_token → target_token - let buy_result = - MostLiquidAlgorithm::simulate_path(&path, market, None, self.simulation_amount.clone()) - .map_err(|e| { - ComputationError::SimulationFailed(format!("buy simulation failed: {}", e)) - })?; - let buy_gas_units = buy_result.route().total_gas(); - let buy_gas_cost = &buy_gas_units * gas_price; // Convert gas units to actual cost - let buy_out = buy_result - .into_route() - .into_swaps() - .into_iter() - .last() - .ok_or(ComputationError::Internal("no output from buy simulation".into()))? - .amount_out() - .clone(); - - // Reverse: target_token → gas_token - let reversed_path = path.reversed(); - - let sell_result = - MostLiquidAlgorithm::simulate_path(&reversed_path, market, None, buy_out.clone()) - .map_err(|e| { - ComputationError::SimulationFailed(format!("sell simulation failed: {}", e)) - })?; - let sell_gas_units = sell_result.route().total_gas(); - let sell_gas_cost = &sell_gas_units * gas_price; // Convert gas units to actual cost - let sell_out = sell_result - .into_route() - .into_swaps() - .into_iter() - .last() - .ok_or(ComputationError::Internal("no output from sell simulation".into()))? - .amount_out() - .clone(); - - // Convert to f64 for mid_price calculation + + let (reverse_route, sell_out) = + resimulate_path(&reversed_path, &buy_out, market, token_map).map_err(|e| { + ComputationError::SimulationFailed(format!("sell simulation failed: {}", e)) + })?; + let sell_gas_cost = reverse_route.total_gas() * gas_price; + + // Convert to f64 for spread calculation let buy_out_f = buy_out .to_f64() .ok_or(ComputationError::Internal("overflow computing buy_out".into()))?; @@ -282,77 +164,93 @@ impl TokenGasPriceComputation { let spread = (sell_price - buy_price).abs(); // Compute mid_price in numerator/denominator form (precise BigUint arithmetic) - // numerator = buy_out * (sell_out - sell_gas_cost) + buy_out * (sim_amount + buy_gas_cost) - // denominator = 2 * (sim_amount + buy_gas_cost) * (sell_out - sell_gas_cost) let sell_out_net = &sell_out - &sell_gas_cost; // Safe: checked above let buy_price_precise = Price { - numerator: &buy_out * &sell_out_net + - &buy_out * (&self.simulation_amount + &buy_gas_cost), - denominator: BigUint::from(2u8) * - (&self.simulation_amount + &buy_gas_cost) * - sell_out_net, + numerator: &buy_out * &sell_out_net + + &buy_out * (&self.simulation_amount + &buy_gas_cost), + denominator: BigUint::from(2u8) + * (&self.simulation_amount + &buy_gas_cost) + * sell_out_net, }; Ok((spread, buy_price_precise, path_components)) } - /// Core simulation logic: discovers paths, runs round-robin simulation, - /// returns best prices with dependency tracking. + /// Core pricing logic: runs BF forward pass, then reverse-simulates each winning path. /// - /// If `filter_tokens` is `Some`, only simulates those tokens (incremental mode). - /// If `None`, simulates all discovered tokens (full mode). + /// If `filter_tokens` is `Some`, only prices those tokens (incremental mode). + /// If `None`, prices all reachable tokens (full mode). #[allow(clippy::type_complexity)] fn simulate_token_prices( &self, market: &SharedMarketData, - spot_prices: &SpotPrices, gas_price: &BigUint, filter_tokens: Option<&HashSet
>, ) -> Result)>, ComputationError> { let mut graph_manager = PetgraphStableDiGraphManager::new(); graph_manager.initialize_graph(&market.component_topology()); + let graph = graph_manager.graph(); - let mut paths_by_token = self.discover_paths(&graph_manager, spot_prices)?; - - // Optionally filter to only requested tokens - if let Some(tokens) = filter_tokens { - paths_by_token.retain(|token, _| tokens.contains(token)); - } + // If gas token has no pools, it won't be in the graph + let Ok(source_node) = graph_manager.find_node(&self.gas_token) else { + return Ok(HashMap::new()); + }; - // Sort each token's paths: lowest spread last (for popping) - for paths in paths_by_token.values_mut() { - paths.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap()); - } + // BF forward pass: prices all tokens in one traversal + let spfa_result = solve_one_to_all( + source_node, + self.simulation_amount.clone(), + self.max_hops, + graph, + market, + ); - // Round-robin: pop one candidate per token each round, keep best by spread + let token_map = spfa_result.token_map(); let mut best_prices: HashMap)> = HashMap::new(); - let mut candidates_exhausted = false; - while !candidates_exhausted { - candidates_exhausted = true; + for (&node, token) in token_map { + // Skip source token and unreachable tokens + if token.address == self.gas_token || !spfa_result.is_reachable(node) { + continue; + } - for (token, candidate_paths) in paths_by_token.iter_mut() { - let Some(candidate) = candidate_paths.pop() else { + // Optionally filter to only requested tokens + if let Some(filter) = filter_tokens { + if !filter.contains(&token.address) { continue; - }; - candidates_exhausted = false; - - match self.compute_spread_and_mid_price(candidate.path, market, gas_price) { - Ok((spread, price, components)) => { - let is_better = best_prices - .get(token) - .map(|(existing_spread, _, _)| spread < *existing_spread) - .unwrap_or(true); - if is_better { - trace!( - token = ?token, - spread_ratio = spread, - "found better price (lower spread)" - ); - best_prices.insert(token.clone(), (spread, price, components)); - } - } - Err(_) => continue, + } + } + + // Reconstruct the winning forward path + let forward_path = match spfa_result.reconstruct_path(node) { + Ok(p) => p, + Err(e) => { + trace!( + token = ?token.address, + error = %e, + "failed to reconstruct path, skipping" + ); + continue; + } + }; + + // Compute spread and mid-price via forward + reverse simulation + match self.compute_spread_and_mid_price(&forward_path, market, gas_price, &spfa_result) + { + Ok((spread, price, components)) => { + trace!( + token = ?token.address, + spread_ratio = spread, + "computed token price" + ); + best_prices.insert(token.address.clone(), (spread, price, components)); + } + Err(e) => { + trace!( + token = ?token.address, + error = %e, + "price computation failed, skipping" + ); } } } @@ -415,24 +313,13 @@ impl TokenGasPriceComputation { .map(|b| b.number()) .unwrap_or(0); - let store_guard = store.read().await; - let spot_prices = store_guard - .spot_prices() - .ok_or(ComputationError::MissingDependency("spot_prices"))? - .clone(); - drop(store_guard); - let gas_price = market .gas_price() .ok_or(ComputationError::MissingDependency("gas_price"))? .effective_gas_price(); - let best_prices = self.simulate_token_prices( - &market, - &spot_prices, - &gas_price, - Some(&tokens_to_recompute), - )?; + let best_prices = + self.simulate_token_prices(&market, &gas_price, Some(&tokens_to_recompute))?; // Merge results into existing prices and deps let mut result = existing_prices; @@ -477,36 +364,27 @@ impl DerivedComputation for TokenGasPriceComputation { // For topology changes or full recompute, do a full computation // For state-only changes, use incremental computation if !changed.is_full_recompute && !changed.is_topology_change() { - // Try incremental computation if we have existing path dependencies if let Some(result) = self .try_incremental_compute(market, store, changed) .await? { return Ok(result); } - // Fall through to full compute if incremental is not possible } let market = market.read().await; - let store_guard = store.read().await; let block = market .last_updated() .map(|b| b.number()) .unwrap_or(0); - let spot_prices = store_guard - .spot_prices() - .ok_or(ComputationError::MissingDependency("spot_prices"))? - .clone(); - drop(store_guard); - let gas_price = market .gas_price() .ok_or(ComputationError::MissingDependency("gas_price"))? .effective_gas_price(); - let best_prices = self.simulate_token_prices(&market, &spot_prices, &gas_price, None)?; + let best_prices = self.simulate_token_prices(&market, &gas_price, None)?; // Build token prices with dependencies for incremental computation let mut token_prices_with_deps = TokenPricesWithDeps::new(); @@ -549,7 +427,7 @@ mod tests { use super::*; use crate::{ algorithm::test_utils::{component, market_read, setup_market, token, MockProtocolSim}, - derived::{computations::spot_price::SpotPriceComputation, store::DerivedData}, + derived::store::DerivedData, }; // ==================== Constants ==================== @@ -561,203 +439,21 @@ mod tests { // ==================== Test Helpers ==================== - /// Sets up a complete test environment: market with pools + precomputed spot prices. + /// Sets up a complete test environment: market with pools. /// Returns (market_guard, store) ready for computation. async fn setup_test_env( pools: Vec<(&str, &Token, &Token, MockProtocolSim)>, ) -> (SharedMarketDataRef, SharedDerivedDataRef) { - let (wrapped_market, _) = setup_market(pools.clone()); - + let (wrapped_market, _) = setup_market(pools); let wrapped_store = DerivedData::new_shared(); - let spot_comp = SpotPriceComputation::new(); - let changed = ChangedComponents { - added: pools - .iter() - .map(|(id, t1, t2, _)| { - (id.to_string(), vec![t1.address.clone(), t2.address.clone()]) - }) - .collect(), - removed: vec![], - updated: vec![], - is_full_recompute: true, - }; - let spot_prices = spot_comp - .compute(&wrapped_market, &wrapped_store, &changed) - .await - .expect("spot price computation should succeed"); - wrapped_store - .try_write() - .unwrap() - .set_spot_prices(spot_prices, 0); - (wrapped_market, wrapped_store) } - async fn setup_graph_and_spot_prices( - pools: Vec<(&str, &Token, &Token, MockProtocolSim)>, - ) -> (PetgraphStableDiGraphManager<()>, SpotPrices) { - let (market, derived) = setup_test_env(pools).await; - let market = market_read(&market); - - let mut graph = PetgraphStableDiGraphManager::new(); - graph.initialize_graph(&market.component_topology()); - - let spot_prices = derived - .try_write() - .unwrap() - .spot_prices() - .unwrap() - .clone(); - (graph, spot_prices) - } - /// Creates a computation configured for the given gas token with standard settings. fn computation_for(gas_token: &Address) -> TokenGasPriceComputation { TokenGasPriceComputation::new(gas_token.clone(), 2, BigUint::from(SIM_AMOUNT)) } - // ==================== discover_paths tests ==================== - - #[tokio::test] - async fn test_discover_paths_single_hop() { - let eth = token(0, "ETH"); - let usdc = token(1, "USDC"); - - let (graph_manager, spot_prices) = - setup_graph_and_spot_prices(vec![("pool", ð, &usdc, MockProtocolSim::new(2000.0))]) - .await; - - let computation = computation_for(ð.address); - let paths = computation - .discover_paths(&graph_manager, &spot_prices) - .unwrap(); - - // Exactly 1 path to USDC (single hop via "pool") - let usdc_paths = &paths[&usdc.address]; - assert_eq!(usdc_paths.len(), 1, "should have exactly 1 path to USDC"); - - let path = &usdc_paths[0]; - assert_eq!(path.path.len(), 1, "path should be single hop"); - assert_eq!(path.path.edge_data[0].component_id, "pool"); - - // For a symmetric pool, spread = 0 - assert_eq!(path.score, 0.0); - } - - #[tokio::test] - async fn test_discover_paths_multi_hop() { - let eth = token(0, "ETH"); - let mid = token(2, "MID"); - let target = token(3, "TARGET"); - - let (graph, spot_prices) = setup_graph_and_spot_prices(vec![ - ("hop1", ð, &mid, MockProtocolSim::new(2.0)), - ("hop2", &mid, &target, MockProtocolSim::new(3.0)), - ]) - .await; - - let computation = computation_for(ð.address); - let paths = computation - .discover_paths(&graph, &spot_prices) - .unwrap(); - - // MID: exactly 1 path (1-hop via hop1) - let mid_paths = &paths[&mid.address]; - assert_eq!(mid_paths.len(), 1, "should have exactly 1 path to MID"); - assert_eq!(mid_paths[0].path.len(), 1, "MID path should be 1 hop"); - assert_eq!(mid_paths[0].path.edge_data[0].component_id, "hop1"); - assert_eq!(mid_paths[0].score, 0.0); - - // TARGET: exactly 1 path (2-hop via hop1 → hop2) - let target_paths = &paths[&target.address]; - assert_eq!(target_paths.len(), 1, "should have exactly 1 path to TARGET"); - assert_eq!(target_paths[0].path.len(), 2, "TARGET path should be 2 hops"); - assert_eq!(target_paths[0].path.edge_data[0].component_id, "hop1"); - assert_eq!(target_paths[0].path.edge_data[1].component_id, "hop2"); - assert_eq!(target_paths[0].score, 0.0); - } - - #[tokio::test] - async fn test_discover_paths_respects_max_hops() { - let eth = token(0, "ETH"); - let a = token(2, "A"); - let b = token(3, "B"); - let c = token(4, "C"); - - let (graph, spot_prices) = setup_graph_and_spot_prices(vec![ - ("eth_a", ð, &a, MockProtocolSim::new(2.0)), - ("a_b", &a, &b, MockProtocolSim::new(2.0)), - ("b_c", &b, &c, MockProtocolSim::new(2.0)), - ]) - .await; - - // max_hops = 2 - let computation = computation_for(ð.address); - let paths = computation - .discover_paths(&graph, &spot_prices) - .unwrap(); - - // A: exactly 1 path (1 hop via eth_a) - let a_paths = &paths[&a.address]; - assert_eq!(a_paths.len(), 1, "should have exactly 1 path to A"); - assert_eq!(a_paths[0].path.len(), 1, "A path should be 1 hop"); - assert_eq!(a_paths[0].path.edge_data[0].component_id, "eth_a"); - assert_eq!(a_paths[0].score, 0.0); - - // B: exactly 1 path (2 hops via eth_a → a_b) - let b_paths = &paths[&b.address]; - assert_eq!(b_paths.len(), 1, "should have exactly 1 path to B"); - assert_eq!(b_paths[0].path.len(), 2, "B path should be 2 hops"); - assert_eq!(b_paths[0].path.edge_data[0].component_id, "eth_a"); - assert_eq!(b_paths[0].path.edge_data[1].component_id, "a_b"); - assert_eq!(b_paths[0].score, 0.0); - - // C: not reachable (would require 3 hops, exceeds max_hops=2) - assert!(!paths.contains_key(&c.address), "C should NOT be reachable (3 hops)"); - } - - #[tokio::test] - async fn test_discover_paths_returns_multiple_candidates() { - let eth = token(0, "ETH"); - let usdc = token(1, "USDC"); - - // Two pools with different spot prices - let (graph, spot_prices) = setup_graph_and_spot_prices(vec![ - ("pool_low", ð, &usdc, MockProtocolSim::new(1000.0)), - ("pool_high", ð, &usdc, MockProtocolSim::new(2000.0)), - ]) - .await; - - let computation = computation_for(ð.address); - let paths = computation - .discover_paths(&graph, &spot_prices) - .unwrap(); - - // Exactly 2 paths to USDC (one via each pool) - let usdc_paths = &paths[&usdc.address]; - assert_eq!(usdc_paths.len(), 2, "should have exactly 2 paths to USDC"); - - // MockProtocolSim's spot_price is symmetric: forward_spot = 1/reverse_spot, - // so spread = |forward - 1/reverse| = 0 for all pools. - // TODO: Test with asymmetric simulation component to verify non-zero spread ranking. - for path in usdc_paths { - assert_eq!(path.path.len(), 1, "path should be single hop"); - assert_eq!(path.score, 0.0, "symmetric mock produces zero spread"); - } - - // Verify both pools are discovered (order is arbitrary when scores are equal) - let component_ids: Vec<_> = usdc_paths - .iter() - .map(|p| { - p.path.edge_data[0] - .component_id - .as_str() - }) - .collect(); - assert!(component_ids.contains(&"pool_low")); - assert!(component_ids.contains(&"pool_high")); - } - // ==================== compute_spread_and_mid_price tests ==================== #[tokio::test] @@ -768,22 +464,22 @@ mod tests { // Non-trivial setup: 10% fee + significant gas (10% of sim_amount) // gas_units = 1e15, gas_cost = 1e15 * 100 = 1e17 (10% of 1e18) // - // Forward (ETH→USDC): + // Forward (ETH->USDC): // buy_out = 1e18 * 2000 * 0.9 = 1.8e21 // buy_gas_cost = 1e17 // - // Reverse (USDC→ETH): + // Reverse (USDC->ETH): // sell_out = 1.8e21 / 2000 * 0.9 = 8.1e17 // sell_gas_cost = 1e17 // // buy_price = buy_out / (sim_amount + buy_gas_cost) - // = 1.8e21 / (1e18 + 1e17) = 1.8e21 / 1.1e18 = 18000/11 ≈ 1636.36 + // = 1.8e21 / (1e18 + 1e17) = 1.8e21 / 1.1e18 = 18000/11 // // sell_price = buy_out / (sell_out - sell_gas_cost) - // = 1.8e21 / (8.1e17 - 1e17) = 1.8e21 / 7.1e17 = 180000/71 ≈ 2535.21 + // = 1.8e21 / (8.1e17 - 1e17) = 1.8e21 / 7.1e17 = 180000/71 // - // spread = |sell_price - buy_price| = 180000/71 - 18000/11 = 702000/781 ≈ 898.85 - // mid_price = (buy_price + sell_price) / 2 ≈ 2085.79 + // spread = |sell_price - buy_price| = 180000/71 - 18000/11 + // mid_price = (buy_price + sell_price) / 2 let gas_units: u64 = 1_000_000_000_000_000; // 1e15 let (market, _) = setup_test_env(vec![( "pool", @@ -796,22 +492,27 @@ mod tests { .await; let market = market_read(&market); - // Build path manually using graph - let mut graph = PetgraphStableDiGraphManager::new(); - graph.initialize_graph(&market.component_topology()); + // Build graph and run BF forward pass + let mut graph_manager = PetgraphStableDiGraphManager::new(); + graph_manager.initialize_graph(&market.component_topology()); + let graph = graph_manager.graph(); + let source = graph_manager + .find_node(ð.address) + .unwrap(); - let eth_node = graph.find_node(ð.address).unwrap(); - let path_edges: Vec<_> = graph.graph().edges(eth_node).collect(); - assert_eq!(path_edges.len(), 1); + let spfa_result = solve_one_to_all(source, BigUint::from(SIM_AMOUNT), 2, graph, &market); - let edge = path_edges[0].weight(); - let mut path = Path::new(); - path.add_hop(ð.address, edge, &usdc.address); + let dest = graph_manager + .find_node(&usdc.address) + .unwrap(); + let forward_path = spfa_result + .reconstruct_path(dest) + .unwrap(); let gas_price = BigUint::from(GAS_PRICE); let computation = computation_for(ð.address); let (spread, mid_price, _path_components) = computation - .compute_spread_and_mid_price(path, &market, &gas_price) + .compute_spread_and_mid_price(&forward_path, &market, &gas_price, &spfa_result) .unwrap(); // Expected values from exact fractions @@ -885,7 +586,7 @@ mod tests { } #[tokio::test] - async fn test_compute_selects_best_path_by_spread() { + async fn test_compute_selects_best_path_by_output() { // Diamond topology: two paths to C // // A (10% fee on eth_a) @@ -897,19 +598,18 @@ mod tests { // Only first hops have fees; second hops (a_c, b_c) are fee-free. // Gas = 0 to simplify calculations. // - // Path via A (eth_a=10% fee, a_c=0% fee): - // Forward: 1e18 * 2 * 0.9 * 5 = 9e18 - // Reverse: 9e18 / 5 / 2 * 0.9 = 0.81e18 - // buy_price = 9, sell_price = 9/0.81 = 100/9 - // spread_A = |100/9 - 9| = 19/9 ≈ 2.11 + // BF picks by highest forward output: // - // Path via B (eth_b=5% fee, b_c=0% fee): - // Forward: 1e18 * 3 * 0.95 * 2 = 5.7e18 = (57/10)e18 - // Reverse: 5.7e18 / 2 / 3 * 0.95 = 0.9025e18 = (361/400)e18 - // buy_price = 57/10, sell_price = (57/10)/(361/400) = 2280/361 - // spread_B = |2280/361 - 57/10| = 2223/3610 ≈ 0.62 + // Path via A: 1e18 * 2 * 0.9 * 5 = 9e18 (higher output) + // Path via B: 1e18 * 3 * 0.95 * 2 = 5.7e18 // - // spread_B < spread_A → Path via B selected. + // BF selects path via A for C. + // + // C via A: + // buy_out = 9e18 + // sell: C->A (9e18/5 = 1.8e18) -> A->ETH (1.8e18*0.9/2 = 0.81e18) + // buy_price = 9, sell_price = 9/0.81 = 100/9 + // mid_price = (81+100)/18 = 181/18 let eth = token(0, "ETH"); let a = token(2, "A"); let b = token(3, "B"); @@ -947,10 +647,10 @@ mod tests { assert_eq!(prices.len(), 4, "should have prices for ETH, A, B, C"); // A: 1-hop from ETH with 10% fee - // buy_out = 1e18 * 2 * 0.9 = 1.8e18 = (9/5)e18 - // sell_out = 1.8e18 / 2 * 0.9 = 0.81e18 = (81/100)e18 - // buy_price = 9/5, sell_price = (9/5)/(81/100) = 9*100/(5*81) = 20/9 - // mid_price = (9/5 + 20/9) / 2 = (81 + 100) / 90 = 181/90 + // buy_out = 1e18 * 2 * 0.9 = 1.8e18 + // sell_out = 1.8e18 / 2 * 0.9 = 0.81e18 + // buy_price = 9/5, sell_price = (9/5)/(81/100) = 20/9 + // mid_price = (9/5 + 20/9) / 2 = 181/90 let a_price = prices .get(&a.address) .expect("A should have price"); @@ -962,11 +662,10 @@ mod tests { ); // B: 1-hop from ETH with 5% fee - // buy_out = 1e18 * 3 * 0.95 = 2.85e18 = (57/20)e18 - // sell_out = 2.85e18 / 3 * 0.95 = 0.9025e18 = (361/400)e18 - // buy_price = 57/20, sell_price = (57/20)/(361/400) = 57*400/(20*361) = 1140/361 - // mid_price = (57/20 + 1140/361) / 2 = (57*361 + 1140*20) / (2*20*361) - // = (20577 + 22800) / 14440 = 43377/14440 + // buy_out = 1e18 * 3 * 0.95 = 2.85e18 + // sell_out = 2.85e18 / 3 * 0.95 = 0.9025e18 + // buy_price = 57/20, sell_price = 1140/361 + // mid_price = 43377/14440 let b_price = prices .get(&b.address) .expect("B should have price"); @@ -977,40 +676,19 @@ mod tests { "B mid_price should be 43377/14440 = {expected_b}, got {b_ratio}" ); - // C: Path via B selected (lower spread) - // buy_out = 1e18 * 3 * 0.95 * 2 = 5.7e18 = (57/10)e18 - // sell_out = 5.7e18 / 2 / 3 * 0.95 = 0.9025e18 = (361/400)e18 - // buy_price = 57/10, sell_price = (57/10)/(361/400) = 2280/361 - // mid_price = (57/10 + 2280/361) / 2 = (20577 + 22800) / 7220 = 43377/7220 + // C: BF selects path via A (higher output: 9e18 > 5.7e18) + // buy_out = 9e18 + // sell_out = 0.81e18 + // buy_price = 9, sell_price = 100/9 + // mid_price = 181/18 let c_price = prices .get(&c.address) .expect("C should have price"); let c_ratio = c_price.numerator.to_f64().unwrap() / c_price.denominator.to_f64().unwrap(); - let expected_c = 43377.0 / 7220.0; + let expected_c = 181.0 / 18.0; assert!( (c_ratio - expected_c).abs() < 1e-10, - "C mid_price should be 43377/7220 = {expected_c} (via B), got {c_ratio}" - ); - } - - #[tokio::test] - async fn test_compute_missing_spot_prices_returns_error() { - let eth = token(0, "ETH"); - let usdc = token(1, "USDC"); - - // Create market without spot prices set - let (market, _) = setup_market(vec![("pool", ð, &usdc, MockProtocolSim::new(2000.0))]); - let derived = DerivedData::new_shared(); // No spot prices - let changed = ChangedComponents::default(); - - let computation = computation_for(ð.address); - let result = computation - .compute(&market, &derived, &changed) - .await; - - assert!( - matches!(result, Err(ComputationError::MissingDependency("spot_prices"))), - "should return MissingDependency for spot_prices" + "C mid_price should be 181/18 = {expected_c} (via A, highest output), got {c_ratio}" ); } @@ -1055,7 +733,6 @@ mod tests { market.upsert_tokens([eth.clone(), usdc.clone()]); let market = SharedMarketData::new_shared(); - // Compute spot prices let derived = DerivedData::new_shared(); let changed = ChangedComponents { added: std::collections::HashMap::from([( @@ -1067,16 +744,6 @@ mod tests { is_full_recompute: true, }; - let spot_comp = SpotPriceComputation::new(); - let spot_prices = spot_comp - .compute(&market, &derived, &changed) - .await - .unwrap(); - derived - .try_write() - .unwrap() - .set_spot_prices(spot_prices, 0); - let computation = computation_for(ð.address); let result = computation .compute(&market, &derived, &changed) @@ -1087,4 +754,63 @@ mod tests { "should return MissingDependency for gas_price" ); } + + #[tokio::test] + async fn test_compute_respects_max_hops() { + let eth = token(0, "ETH"); + let a = token(2, "A"); + let b = token(3, "B"); + let c = token(4, "C"); + + let (market, derived) = setup_test_env(vec![ + ("eth_a", ð, &a, MockProtocolSim::new(2)), + ("a_b", &a, &b, MockProtocolSim::new(2)), + ("b_c", &b, &c, MockProtocolSim::new(2)), + ]) + .await; + let changed = ChangedComponents::default(); + + // max_hops = 2 + let computation = computation_for(ð.address); + let prices = computation + .compute(&market, &derived, &changed) + .await + .unwrap(); + + // A (1 hop) and B (2 hops) should be priced, C (3 hops) should not + assert!(prices.contains_key(&a.address), "A should be priced (1 hop)"); + assert!(prices.contains_key(&b.address), "B should be priced (2 hops)"); + assert!(!prices.contains_key(&c.address), "C should NOT be priced (3 hops)"); + } + + #[tokio::test] + async fn test_compute_multiple_pools_same_pair() { + let eth = token(0, "ETH"); + let usdc = token(1, "USDC"); + + // Two pools with different spot prices; BF picks higher output + let (market, derived) = setup_test_env(vec![ + ("pool_low", ð, &usdc, MockProtocolSim::new(1000)), + ("pool_high", ð, &usdc, MockProtocolSim::new(2000)), + ]) + .await; + let changed = ChangedComponents::default(); + + let computation = computation_for(ð.address); + let prices = computation + .compute(&market, &derived, &changed) + .await + .unwrap(); + + // BF picks pool_high (higher output: 2000 > 1000) + let usdc_price = prices + .get(&usdc.address) + .expect("USDC should have price"); + let ratio = + usdc_price.numerator.to_f64().unwrap() / usdc_price.denominator.to_f64().unwrap(); + assert!( + (ratio - 2000.0).abs() < 1e-6, + "mid-price should be ~2000 (via pool_high), got {ratio}" + ); + } } diff --git a/fynd-core/src/graph/mod.rs b/fynd-core/src/graph/mod.rs index b52b755b..33e2235f 100644 --- a/fynd-core/src/graph/mod.rs +++ b/fynd-core/src/graph/mod.rs @@ -73,6 +73,7 @@ impl<'a, D> Path<'a, D> { } /// Creates a new reversed Path from the current one. + #[allow(dead_code)] pub fn reversed(self) -> Self { let reversed_tokens = self.tokens.into_iter().rev().collect(); let reversed_edge_data = self From c87633c5020b32cadb568d8ebdd145f8f70c71ed Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 16 Mar 2026 16:38:42 -0600 Subject: [PATCH 2/4] Remove dead code and update stale dependency docs - Remove unused Path::reversed() method (was only needed by DFS-based pricing) - Update dependency graph docs: TokenGasPriceComputation no longer depends on spot_prices - Fix PoolDepthComputation dependency comment (it depends on spot_prices, not "no dependencies") Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/algorithm/bellman_ford_pricing.rs | 46 ++++++++++--------- .../derived/computations/token_gas_price.rs | 10 ++-- fynd-core/src/derived/manager.rs | 10 ++-- fynd-core/src/derived/mod.rs | 6 +-- fynd-core/src/graph/mod.rs | 12 ----- 5 files changed, 37 insertions(+), 47 deletions(-) diff --git a/fynd-core/src/algorithm/bellman_ford_pricing.rs b/fynd-core/src/algorithm/bellman_ford_pricing.rs index 019f8726..3c08aadc 100644 --- a/fynd-core/src/algorithm/bellman_ford_pricing.rs +++ b/fynd-core/src/algorithm/bellman_ford_pricing.rs @@ -259,15 +259,17 @@ pub(crate) fn resimulate_path( error: format!("{:?}", e), })?; - swaps.push(Swap { - component_id: component_id.clone(), - protocol: component.protocol_system.clone(), - token_in: token_in.address.clone(), - token_out: token_out.address.clone(), - amount_in: current_amount.clone(), - amount_out: result.amount.clone(), - gas_estimate: result.gas, - }); + swaps.push(Swap::new( + component_id.clone(), + component.protocol_system.clone(), + token_in.address.clone(), + token_out.address.clone(), + current_amount.clone(), + result.amount.clone(), + result.gas, + component.clone(), + state.clone_box(), + )); if is_vm { vm_state_override = Some(result.new_state); @@ -428,8 +430,8 @@ mod tests { let dai = token(2, "DAI"); let (market, gm) = setup_market_and_graph(vec![ - ("eth_usdc", ð, &usdc, MockProtocolSim::new(2000)), - ("usdc_dai", &usdc, &dai, MockProtocolSim::new(1)), + ("eth_usdc", ð, &usdc, MockProtocolSim::new(2000.0)), + ("usdc_dai", &usdc, &dai, MockProtocolSim::new(1.0)), ]); let graph = gm.graph(); @@ -466,10 +468,10 @@ mod tests { let target = token(3, "TARGET"); let (market, gm) = setup_market_and_graph(vec![ - ("eth_a", ð, &a, MockProtocolSim::new(2)), - ("a_target", &a, &target, MockProtocolSim::new(3)), - ("eth_b", ð, &b, MockProtocolSim::new(4)), - ("b_target", &b, &target, MockProtocolSim::new(1)), + ("eth_a", ð, &a, MockProtocolSim::new(2.0)), + ("a_target", &a, &target, MockProtocolSim::new(3.0)), + ("eth_b", ð, &b, MockProtocolSim::new(4.0)), + ("b_target", &b, &target, MockProtocolSim::new(1.0)), ]); let graph = gm.graph(); @@ -497,9 +499,9 @@ mod tests { let c = token(3, "C"); let (market, gm) = setup_market_and_graph(vec![ - ("eth_a", ð, &a, MockProtocolSim::new(2)), - ("a_b", &a, &b, MockProtocolSim::new(3)), - ("b_c", &b, &c, MockProtocolSim::new(4)), + ("eth_a", ð, &a, MockProtocolSim::new(2.0)), + ("a_b", &a, &b, MockProtocolSim::new(3.0)), + ("b_c", &b, &c, MockProtocolSim::new(4.0)), ]); let graph = gm.graph(); @@ -524,7 +526,7 @@ mod tests { let usdc = token(1, "USDC"); let (market, gm) = - setup_market_and_graph(vec![("pool", ð, &usdc, MockProtocolSim::new(2000))]); + setup_market_and_graph(vec![("pool", ð, &usdc, MockProtocolSim::new(2000.0))]); let graph = gm.graph(); let eth_node = graph @@ -548,7 +550,7 @@ mod tests { // Re-simulate let (route, amount_out) = resimulate_path(&path, &BigUint::from(100u64), &market, result.token_map()).unwrap(); - assert_eq!(route.swaps.len(), 1); + assert_eq!(route.swaps().len(), 1); assert_eq!(amount_out, BigUint::from(200_000u64)); } @@ -560,8 +562,8 @@ mod tests { let b = token(2, "B"); let (market, gm) = setup_market_and_graph(vec![ - ("eth_a", ð, &a, MockProtocolSim::new(2)), - ("a_b", &a, &b, MockProtocolSim::new(3)), + ("eth_a", ð, &a, MockProtocolSim::new(2.0)), + ("a_b", &a, &b, MockProtocolSim::new(3.0)), ]); let graph = gm.graph(); diff --git a/fynd-core/src/derived/computations/token_gas_price.rs b/fynd-core/src/derived/computations/token_gas_price.rs index d074eae2..c3af3010 100644 --- a/fynd-core/src/derived/computations/token_gas_price.rs +++ b/fynd-core/src/derived/computations/token_gas_price.rs @@ -763,9 +763,9 @@ mod tests { let c = token(4, "C"); let (market, derived) = setup_test_env(vec![ - ("eth_a", ð, &a, MockProtocolSim::new(2)), - ("a_b", &a, &b, MockProtocolSim::new(2)), - ("b_c", &b, &c, MockProtocolSim::new(2)), + ("eth_a", ð, &a, MockProtocolSim::new(2.0)), + ("a_b", &a, &b, MockProtocolSim::new(2.0)), + ("b_c", &b, &c, MockProtocolSim::new(2.0)), ]) .await; let changed = ChangedComponents::default(); @@ -790,8 +790,8 @@ mod tests { // Two pools with different spot prices; BF picks higher output let (market, derived) = setup_test_env(vec![ - ("pool_low", ð, &usdc, MockProtocolSim::new(1000)), - ("pool_high", ð, &usdc, MockProtocolSim::new(2000)), + ("pool_low", ð, &usdc, MockProtocolSim::new(1000.0)), + ("pool_high", ð, &usdc, MockProtocolSim::new(2000.0)), ]) .await; let changed = ChangedComponents::default(); diff --git a/fynd-core/src/derived/manager.rs b/fynd-core/src/derived/manager.rs index bd59a956..c127450a 100644 --- a/fynd-core/src/derived/manager.rs +++ b/fynd-core/src/derived/manager.rs @@ -247,8 +247,8 @@ impl ComputationManager { /// /// **Dependency order**: /// 1. `SpotPriceComputation` - no dependencies - /// 2. `TokenGasPriceComputation` - depends on spot_prices in store - /// 3. `PoolDepthComputation` - no dependencies (runs in parallel with token prices) + /// 2. `TokenGasPriceComputation` - depends on gas_price (uses BF SPFA, no spot_prices dependency) + /// 3. `PoolDepthComputation` - depends on spot_prices in store async fn compute_all(&self, changed: &ChangedComponents) { let total_start = Instant::now(); @@ -372,9 +372,9 @@ impl MarketEventHandler for ComputationManager { added_components, removed_components, updated_components, - } if !added_components.is_empty() || - !removed_components.is_empty() || - !updated_components.is_empty() => + } if !added_components.is_empty() + || !removed_components.is_empty() + || !updated_components.is_empty() => { trace!( added = added_components.len(), diff --git a/fynd-core/src/derived/mod.rs b/fynd-core/src/derived/mod.rs index 90cff15d..555ff60a 100644 --- a/fynd-core/src/derived/mod.rs +++ b/fynd-core/src/derived/mod.rs @@ -18,14 +18,14 @@ //! //! ```text //! SpotPriceComputation -//! / \ -//! v v +//! / +//! v //! PoolDepthComputation TokenGasPriceComputation //! ``` //! //! - **SpotPriceComputation**: No dependencies, computes spot prices for all pools //! - **PoolDepthComputation**: Depends on `spot_prices` -//! - **TokenGasPriceComputation**: Depends on `spot_prices` and `gas_price` (from market data) +//! - **TokenGasPriceComputation**: Depends on `gas_price` (from market data); uses Bellman-Ford SPFA //! //! # Example //! diff --git a/fynd-core/src/graph/mod.rs b/fynd-core/src/graph/mod.rs index 33e2235f..22302f73 100644 --- a/fynd-core/src/graph/mod.rs +++ b/fynd-core/src/graph/mod.rs @@ -71,18 +71,6 @@ impl<'a, D> Path<'a, D> { .zip(self.edge_data.iter()) .map(|(tokens, edge)| (tokens[0], *edge, tokens[1])) } - - /// Creates a new reversed Path from the current one. - #[allow(dead_code)] - pub fn reversed(self) -> Self { - let reversed_tokens = self.tokens.into_iter().rev().collect(); - let reversed_edge_data = self - .edge_data - .into_iter() - .rev() - .collect(); - Self { tokens: reversed_tokens, edge_data: reversed_edge_data } - } } #[derive(Error, Debug)] From b90fb3e217ae0a3371c3407bc745a77f204f97b3 Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 23 Mar 2026 15:03:53 -0600 Subject: [PATCH 3/4] Address PR review: deduplicate BF helpers, remove dead code, simplify resimulate - Extract shared helpers (path_has_conflict, reconstruct_path, extract_subgraph_edges) into bf_helpers.rs, used by both routing and pricing BF modules - Remove dead amount_at method (moved to #[cfg(test)] only) - Simplify resimulate_path: remove state-override tracking since forbid-revisits guarantees each pool appears at most once - Fix doc comment to use proper module link instead of file reference - Remove unused ProtocolSim import and dead simulation_amount accessor Co-Authored-By: Claude Opus 4.6 --- fynd-core/src/algorithm/bellman_ford.rs | 80 +------- .../src/algorithm/bellman_ford_pricing.rs | 188 +++--------------- fynd-core/src/algorithm/bf_helpers.rs | 184 +++++++++++++++++ fynd-core/src/algorithm/mod.rs | 1 + .../derived/computations/token_gas_price.rs | 4 - 5 files changed, 218 insertions(+), 239 deletions(-) create mode 100644 fynd-core/src/algorithm/bf_helpers.rs diff --git a/fynd-core/src/algorithm/bellman_ford.rs b/fynd-core/src/algorithm/bellman_ford.rs index 0ac30155..5ec56d37 100644 --- a/fynd-core/src/algorithm/bellman_ford.rs +++ b/fynd-core/src/algorithm/bellman_ford.rs @@ -27,7 +27,7 @@ use tycho_simulation::{ tycho_core::{models::token::Token, simulation::protocol_sim::Price}, }; -use super::{Algorithm, AlgorithmConfig, AlgorithmError, NoPathReason}; +use super::{bf_helpers, Algorithm, AlgorithmConfig, AlgorithmError, NoPathReason}; use crate::{ derived::{ computation::ComputationRequirements, @@ -140,68 +140,6 @@ impl BellmanFordAlgorithm { None } - /// Checks whether the target node or pool conflicts with the existing path to `from`. - /// Walks the predecessor chain once, checking both conditions simultaneously. - fn path_has_conflict( - from: NodeIndex, - target_node: NodeIndex, - target_pool: &ComponentId, - predecessor: &[Option<(NodeIndex, ComponentId)>], - ) -> bool { - let mut current = from; - loop { - if current == target_node { - return true; - } - match &predecessor[current.index()] { - Some((prev, cid)) => { - if cid == target_pool { - return true; - } - current = *prev; - } - None => return false, - } - } - } - - /// Reconstructs the path from token_out back to token_in by walking the predecessor - /// array. - fn reconstruct_path( - token_out: NodeIndex, - token_in: NodeIndex, - predecessor: &[Option<(NodeIndex, ComponentId)>], - ) -> Result, AlgorithmError> { - let mut path = Vec::new(); - let mut current = token_out; - let mut visited = HashSet::new(); - - while current != token_in { - if !visited.insert(current) { - return Err(AlgorithmError::Other("cycle in predecessor chain".to_string())); - } - - let idx = current.index(); - match &predecessor - .get(idx) - .and_then(|p| p.as_ref()) - { - Some((prev_node, component_id)) => { - path.push((*prev_node, current, component_id.clone())); - current = *prev_node; - } - None => { - return Err(AlgorithmError::Other(format!( - "broken predecessor chain at node {idx}" - ))); - } - } - } - - path.reverse(); - Ok(path) - } - /// Extracts the subgraph reachable from `token_in_node` within `max_hops` via BFS. /// /// Returns `(adjacency_list, token_nodes, component_ids)` or `NoPath` if the @@ -455,7 +393,7 @@ impl Algorithm for BellmanFordAlgorithm { let v_idx = v.index(); // Single predecessor walk: skip if target token or pool already in path - if Self::path_has_conflict(u, *v, component_id, &predecessor) { + if bf_helpers::path_has_conflict(u, *v, component_id, &predecessor) { continue; } @@ -541,7 +479,7 @@ impl Algorithm for BellmanFordAlgorithm { // Reconstruct path and build route directly from stored distances/gas // (no re-simulation needed since forbid-revisits guarantees relaxation // amounts match sequential execution). - let path_edges = Self::reconstruct_path(token_out_node, token_in_node, &predecessor)?; + let path_edges = bf_helpers::reconstruct_path(token_out_node, token_in_node, &predecessor)?; let mut swaps = Vec::with_capacity(path_edges.len()); for (from_node, to_node, component_id) in &path_edges { @@ -1284,20 +1222,20 @@ mod tests { pred[2] = Some((NodeIndex::new(1), "pool_b".into())); // Node conflicts: node 0 is in path, node 3 is not - assert!(BellmanFordAlgorithm::path_has_conflict( + assert!(bf_helpers::path_has_conflict( NodeIndex::new(2), NodeIndex::new(0), &"any".into(), &pred )); - assert!(!BellmanFordAlgorithm::path_has_conflict( + assert!(!bf_helpers::path_has_conflict( NodeIndex::new(2), NodeIndex::new(3), &"any".into(), &pred )); // Self-check: node 2 is itself in the "path from 2" - assert!(BellmanFordAlgorithm::path_has_conflict( + assert!(bf_helpers::path_has_conflict( NodeIndex::new(2), NodeIndex::new(2), &"any".into(), @@ -1305,19 +1243,19 @@ mod tests { )); // Pool conflicts: pool_a and pool_b are used, pool_c is not - assert!(BellmanFordAlgorithm::path_has_conflict( + assert!(bf_helpers::path_has_conflict( NodeIndex::new(2), NodeIndex::new(3), &"pool_a".into(), &pred )); - assert!(BellmanFordAlgorithm::path_has_conflict( + assert!(bf_helpers::path_has_conflict( NodeIndex::new(2), NodeIndex::new(3), &"pool_b".into(), &pred )); - assert!(!BellmanFordAlgorithm::path_has_conflict( + assert!(!bf_helpers::path_has_conflict( NodeIndex::new(2), NodeIndex::new(3), &"pool_c".into(), diff --git a/fynd-core/src/algorithm/bellman_ford_pricing.rs b/fynd-core/src/algorithm/bellman_ford_pricing.rs index 3c08aadc..0c901c65 100644 --- a/fynd-core/src/algorithm/bellman_ford_pricing.rs +++ b/fynd-core/src/algorithm/bellman_ford_pricing.rs @@ -4,23 +4,19 @@ //! propagating to all reachable tokens within `max_hops`. Uses forbid-revisits to prevent //! paths through arbitrage loops that would distort prices. //! -//! This is the pricing counterpart to the routing BF in `bellman_ford.rs`: +//! Counterpart to the routing BF in [`bellman_ford`](super::bellman_ford): //! - Routing: one source, one destination, real trade amount //! - Pricing: one source, ALL destinations, small probe amount -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet}; use num_bigint::BigUint; use num_traits::Zero; -use petgraph::{graph::NodeIndex, prelude::EdgeRef}; +use petgraph::graph::NodeIndex; use tracing::trace; -use tycho_simulation::{ - evm::{engine_db::tycho_db::PreCachedDB, protocol::vm::state::EVMPoolState}, - tycho_common::simulation::protocol_sim::ProtocolSim, - tycho_core::models::token::Token, -}; +use tycho_simulation::tycho_core::models::token::Token; -use super::AlgorithmError; +use super::{bf_helpers, AlgorithmError}; use crate::{ feed::market_data::SharedMarketData, graph::petgraph::StableDiGraph, @@ -37,12 +33,6 @@ pub(crate) struct SpfaAllResult { } impl SpfaAllResult { - /// Returns the best forward amount reachable at `node`. - #[allow(dead_code)] - pub fn amount_at(&self, node: NodeIndex) -> &BigUint { - &self.distance[node.index()] - } - /// Returns true if `node` was reached by the SPFA. pub fn is_reachable(&self, node: NodeIndex) -> bool { !self.distance[node.index()].is_zero() @@ -53,13 +43,19 @@ impl SpfaAllResult { &self, dest: NodeIndex, ) -> Result, AlgorithmError> { - reconstruct_path(dest, self.source, &self.predecessor) + bf_helpers::reconstruct_path(dest, self.source, &self.predecessor) } /// Returns a reference to the token map built during subgraph extraction. pub fn token_map(&self) -> &HashMap { &self.token_map } + + /// Returns the best forward amount reachable at `node` (test-only). + #[cfg(test)] + pub fn amount_at(&self, node: NodeIndex) -> &BigUint { + &self.distance[node.index()] + } } /// Runs a flat Bellman-Ford SPFA from `source` with `amount`, propagating to all @@ -74,8 +70,7 @@ pub(crate) fn solve_one_to_all( graph: &StableDiGraph<()>, market: &SharedMarketData, ) -> SpfaAllResult { - // Extract subgraph (BFS from source up to max_hops) - let subgraph_edges = extract_subgraph(source, max_hops, graph); + let subgraph_edges = bf_helpers::extract_subgraph_edges(source, max_hops, graph); let max_idx = graph .node_indices() @@ -150,13 +145,8 @@ pub(crate) fn solve_one_to_all( for &(v, component_id) in edges { let v_idx = v.index(); - // Forbid token revisits - if path_contains_node(u, v, &predecessor) { - continue; - } - - // Forbid pool revisits - if path_contains_pool(u, component_id, &predecessor) { + // Forbid token and pool revisits (single predecessor walk) + if bf_helpers::path_has_conflict(u, v, component_id, &predecessor) { continue; } @@ -197,7 +187,10 @@ pub(crate) fn solve_one_to_all( SpfaAllResult { distance, predecessor, source, token_map } } -/// Re-simulates a path with exact amounts and state overrides for revisited pools. +/// Simulates a path to build a Route with Swaps. +/// +/// Forbid-revisits guarantees each pool appears at most once, so each step +/// uses the original pool state directly (no state-override tracking needed). pub(crate) fn resimulate_path( path: &[(NodeIndex, NodeIndex, ComponentId)], amount_in: &BigUint, @@ -207,9 +200,6 @@ pub(crate) fn resimulate_path( let mut current_amount = amount_in.clone(); let mut swaps = Vec::with_capacity(path.len()); - let mut native_state_overrides: HashMap<&ComponentId, Box> = HashMap::new(); - let mut vm_state_override: Option> = None; - for (from_node, to_node, component_id) in path { let token_in = token_map .get(from_node) @@ -230,28 +220,13 @@ pub(crate) fn resimulate_path( kind: "component", id: Some(component_id.clone()), })?; - let component_state = market + let state = market .get_simulation_state(component_id) .ok_or_else(|| AlgorithmError::DataNotFound { kind: "simulation state", id: Some(component_id.clone()), })?; - let is_vm = component_state - .as_any() - .downcast_ref::>() - .is_some(); - - let state_override = if is_vm { - vm_state_override.as_ref() - } else { - native_state_overrides.get(component_id) - }; - - let state = state_override - .map(Box::as_ref) - .unwrap_or(component_state); - let result = state .get_amount_out(current_amount.clone(), token_in, token_out) .map_err(|e| AlgorithmError::SimulationFailed { @@ -271,11 +246,6 @@ pub(crate) fn resimulate_path( state.clone_box(), )); - if is_vm { - vm_state_override = Some(result.new_state); - } else { - native_state_overrides.insert(component_id, result.new_state); - } current_amount = result.amount; } @@ -283,122 +253,12 @@ pub(crate) fn resimulate_path( Ok((route, current_amount)) } -// --- Private helpers --- - -/// Extracts a subgraph via BFS from `start` up to `max_depth` hops. -fn extract_subgraph( - start: NodeIndex, - max_depth: usize, - graph: &StableDiGraph<()>, -) -> Vec<(NodeIndex, NodeIndex, ComponentId)> { - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - let mut edges = Vec::new(); - - visited.insert(start); - queue.push_back((start, 0usize)); - - while let Some((node, depth)) = queue.pop_front() { - if depth >= max_depth { - continue; - } - - for edge in graph.edges(node) { - let target = edge.target(); - let component_id = &edge.weight().component_id; - - edges.push((node, target, component_id.clone())); - - if !visited.contains(&target) { - visited.insert(target); - queue.push_back((target, depth + 1)); - } - } - } - - edges -} - -/// Checks whether `target` node is already in the predecessor path leading to `from`. -fn path_contains_node( - from: NodeIndex, - target: NodeIndex, - predecessor: &[Option<(NodeIndex, ComponentId)>], -) -> bool { - let mut current = from; - loop { - if current == target { - return true; - } - match &predecessor[current.index()] { - Some((prev, _)) => current = *prev, - None => return false, - } - } -} - -/// Checks whether `target_pool` is already used in the predecessor path leading to `from`. -fn path_contains_pool( - from: NodeIndex, - target_pool: &ComponentId, - predecessor: &[Option<(NodeIndex, ComponentId)>], -) -> bool { - let mut current = from; - loop { - match &predecessor[current.index()] { - Some((prev, cid)) => { - if cid == target_pool { - return true; - } - current = *prev; - } - None => return false, - } - } -} - -/// Reconstructs the path from dest back to source by walking the flat predecessor array. -fn reconstruct_path( - dest: NodeIndex, - source: NodeIndex, - predecessor: &[Option<(NodeIndex, ComponentId)>], -) -> Result, AlgorithmError> { - let mut path = Vec::new(); - let mut current = dest; - let mut visited = HashSet::new(); - - while current != source { - if !visited.insert(current) { - return Err(AlgorithmError::Other( - "cycle in predecessor chain during path reconstruction".to_string(), - )); - } - - let idx = current.index(); - if idx >= predecessor.len() { - return Err(AlgorithmError::Other("predecessor index out of bounds".to_string())); - } - - match &predecessor[idx] { - Some((prev_node, component_id)) => { - path.push((*prev_node, current, component_id.clone())); - current = *prev_node; - } - None => { - return Err(AlgorithmError::Other(format!( - "broken predecessor chain at node {idx}" - ))); - } - } - } - - path.reverse(); - Ok(path) -} - #[cfg(test)] mod tests { - use tycho_simulation::tycho_core::models::token::Token; + use tycho_simulation::{ + tycho_common::simulation::protocol_sim::ProtocolSim, + tycho_core::models::token::Token, + }; use super::*; use crate::algorithm::test_utils::{component, token, MockProtocolSim}; diff --git a/fynd-core/src/algorithm/bf_helpers.rs b/fynd-core/src/algorithm/bf_helpers.rs new file mode 100644 index 00000000..0aff5fad --- /dev/null +++ b/fynd-core/src/algorithm/bf_helpers.rs @@ -0,0 +1,184 @@ +//! Shared helpers for Bellman-Ford based algorithms (routing and pricing). +//! +//! Both `bellman_ford.rs` (A-to-B routing) and `bellman_ford_pricing.rs` (one-to-all pricing) +//! use forbid-revisits SPFA and share predecessor-chain utilities. + +use std::collections::{HashSet, VecDeque}; + +use petgraph::graph::NodeIndex; +use petgraph::prelude::EdgeRef; + +use super::AlgorithmError; +use crate::{graph::petgraph::StableDiGraph, types::ComponentId}; + +/// Checks whether extending the path at `from` to `target_node` via `target_pool` +/// would create a node or pool revisit. Single walk of the predecessor chain. +pub(crate) fn path_has_conflict( + from: NodeIndex, + target_node: NodeIndex, + target_pool: &ComponentId, + predecessor: &[Option<(NodeIndex, ComponentId)>], +) -> bool { + let mut current = from; + loop { + if current == target_node { + return true; + } + match &predecessor[current.index()] { + Some((prev, cid)) => { + if cid == target_pool { + return true; + } + current = *prev; + } + None => return false, + } + } +} + +/// Reconstructs the path from `dest` back to `source` by walking the predecessor array. +pub(crate) fn reconstruct_path( + dest: NodeIndex, + source: NodeIndex, + predecessor: &[Option<(NodeIndex, ComponentId)>], +) -> Result, AlgorithmError> { + let mut path = Vec::new(); + let mut current = dest; + let mut visited = HashSet::new(); + + while current != source { + if !visited.insert(current) { + return Err(AlgorithmError::Other( + "cycle in predecessor chain".to_string(), + )); + } + + let idx = current.index(); + match predecessor.get(idx).and_then(|p| p.as_ref()) { + Some((prev_node, component_id)) => { + path.push((*prev_node, current, component_id.clone())); + current = *prev_node; + } + None => { + return Err(AlgorithmError::Other(format!( + "broken predecessor chain at node {idx}" + ))); + } + } + } + + path.reverse(); + Ok(path) +} + +/// Extracts subgraph edges via BFS from `start` up to `max_depth` hops. +pub(crate) fn extract_subgraph_edges( + start: NodeIndex, + max_depth: usize, + graph: &StableDiGraph<()>, +) -> Vec<(NodeIndex, NodeIndex, ComponentId)> { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + let mut edges = Vec::new(); + + visited.insert(start); + queue.push_back((start, 0usize)); + + while let Some((node, depth)) = queue.pop_front() { + if depth >= max_depth { + continue; + } + + for edge in graph.edges(node) { + let target = edge.target(); + let component_id = &edge.weight().component_id; + + edges.push((node, target, component_id.clone())); + + if visited.insert(target) { + queue.push_back((target, depth + 1)); + } + } + } + + edges +} + +#[cfg(test)] +mod tests { + use petgraph::graph::NodeIndex; + + use super::*; + + #[test] + fn path_has_conflict_detects_node_and_pool() { + // Path: 0 -[pool_a]-> 1 -[pool_b]-> 2 + let mut pred: Vec> = vec![None; 4]; + pred[1] = Some((NodeIndex::new(0), "pool_a".into())); + pred[2] = Some((NodeIndex::new(1), "pool_b".into())); + + // Node conflict: node 0 is in path + assert!(path_has_conflict( + NodeIndex::new(2), + NodeIndex::new(0), + &"any".into(), + &pred + )); + // No conflict: node 3 is not in path + assert!(!path_has_conflict( + NodeIndex::new(2), + NodeIndex::new(3), + &"any".into(), + &pred + )); + // Self-check: node 2 is itself in the path + assert!(path_has_conflict( + NodeIndex::new(2), + NodeIndex::new(2), + &"any".into(), + &pred + )); + // Pool conflict: pool_a used + assert!(path_has_conflict( + NodeIndex::new(2), + NodeIndex::new(3), + &"pool_a".into(), + &pred + )); + // Pool conflict: pool_b used + assert!(path_has_conflict( + NodeIndex::new(2), + NodeIndex::new(3), + &"pool_b".into(), + &pred + )); + // No pool conflict: pool_c not used + assert!(!path_has_conflict( + NodeIndex::new(2), + NodeIndex::new(3), + &"pool_c".into(), + &pred + )); + } + + #[test] + fn reconstruct_path_simple() { + // Path: 0 -[pool_a]-> 1 -[pool_b]-> 2 + let mut pred: Vec> = vec![None; 3]; + pred[1] = Some((NodeIndex::new(0), "pool_a".into())); + pred[2] = Some((NodeIndex::new(1), "pool_b".into())); + + let path = + reconstruct_path(NodeIndex::new(2), NodeIndex::new(0), &pred).unwrap(); + assert_eq!(path.len(), 2); + assert_eq!(path[0], (NodeIndex::new(0), NodeIndex::new(1), "pool_a".into())); + assert_eq!(path[1], (NodeIndex::new(1), NodeIndex::new(2), "pool_b".into())); + } + + #[test] + fn reconstruct_path_broken_chain() { + let pred: Vec> = vec![None; 3]; + let result = reconstruct_path(NodeIndex::new(2), NodeIndex::new(0), &pred); + assert!(result.is_err()); + } +} diff --git a/fynd-core/src/algorithm/mod.rs b/fynd-core/src/algorithm/mod.rs index 89b0290b..81bdacf8 100644 --- a/fynd-core/src/algorithm/mod.rs +++ b/fynd-core/src/algorithm/mod.rs @@ -18,6 +18,7 @@ //! 2. Implement the `Algorithm` trait //! 3. Register it in `registry.rs` +pub(crate) mod bf_helpers; pub mod bellman_ford; pub mod bellman_ford_pricing; pub mod most_liquid; diff --git a/fynd-core/src/derived/computations/token_gas_price.rs b/fynd-core/src/derived/computations/token_gas_price.rs index c3af3010..bf56d1ac 100644 --- a/fynd-core/src/derived/computations/token_gas_price.rs +++ b/fynd-core/src/derived/computations/token_gas_price.rs @@ -86,10 +86,6 @@ impl TokenGasPriceComputation { Self { gas_token, ..self } } - pub fn simulation_amount(&self) -> &BigUint { - &self.simulation_amount - } - /// Computes spread and mid_price for a given BF path by simulating both directions. /// /// Returns (spread_ratio, mid_price, path_components) where: From 15f49db49345988ce9a46d0752f644d74e2b74ba Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 23 Mar 2026 16:10:08 -0600 Subject: [PATCH 4/4] style: apply nightly rustfmt formatting Co-Authored-By: Claude Opus 4.6 --- .../src/algorithm/bellman_ford_pricing.rs | 13 +++-- fynd-core/src/algorithm/bf_helpers.rs | 57 +++++-------------- fynd-core/src/algorithm/mod.rs | 2 +- .../derived/computations/token_gas_price.rs | 20 +++---- fynd-core/src/derived/manager.rs | 9 +-- fynd-core/src/derived/mod.rs | 3 +- 6 files changed, 38 insertions(+), 66 deletions(-) diff --git a/fynd-core/src/algorithm/bellman_ford_pricing.rs b/fynd-core/src/algorithm/bellman_ford_pricing.rs index 0c901c65..165ea1fa 100644 --- a/fynd-core/src/algorithm/bellman_ford_pricing.rs +++ b/fynd-core/src/algorithm/bellman_ford_pricing.rs @@ -76,8 +76,8 @@ pub(crate) fn solve_one_to_all( .node_indices() .map(|n| n.index()) .max() - .unwrap_or(0) - + 1; + .unwrap_or(0) + + 1; if subgraph_edges.is_empty() { return SpfaAllResult { @@ -256,13 +256,14 @@ pub(crate) fn resimulate_path( #[cfg(test)] mod tests { use tycho_simulation::{ - tycho_common::simulation::protocol_sim::ProtocolSim, - tycho_core::models::token::Token, + tycho_common::simulation::protocol_sim::ProtocolSim, tycho_core::models::token::Token, }; use super::*; - use crate::algorithm::test_utils::{component, token, MockProtocolSim}; - use crate::graph::{GraphManager, PetgraphStableDiGraphManager}; + use crate::{ + algorithm::test_utils::{component, token, MockProtocolSim}, + graph::{GraphManager, PetgraphStableDiGraphManager}, + }; fn setup_market_and_graph( pools: Vec<(&str, &Token, &Token, MockProtocolSim)>, diff --git a/fynd-core/src/algorithm/bf_helpers.rs b/fynd-core/src/algorithm/bf_helpers.rs index 0aff5fad..7a8bc417 100644 --- a/fynd-core/src/algorithm/bf_helpers.rs +++ b/fynd-core/src/algorithm/bf_helpers.rs @@ -5,8 +5,7 @@ use std::collections::{HashSet, VecDeque}; -use petgraph::graph::NodeIndex; -use petgraph::prelude::EdgeRef; +use petgraph::{graph::NodeIndex, prelude::EdgeRef}; use super::AlgorithmError; use crate::{graph::petgraph::StableDiGraph, types::ComponentId}; @@ -48,13 +47,14 @@ pub(crate) fn reconstruct_path( while current != source { if !visited.insert(current) { - return Err(AlgorithmError::Other( - "cycle in predecessor chain".to_string(), - )); + return Err(AlgorithmError::Other("cycle in predecessor chain".to_string())); } let idx = current.index(); - match predecessor.get(idx).and_then(|p| p.as_ref()) { + match predecessor + .get(idx) + .and_then(|p| p.as_ref()) + { Some((prev_node, component_id)) => { path.push((*prev_node, current, component_id.clone())); current = *prev_node; @@ -118,47 +118,17 @@ mod tests { pred[2] = Some((NodeIndex::new(1), "pool_b".into())); // Node conflict: node 0 is in path - assert!(path_has_conflict( - NodeIndex::new(2), - NodeIndex::new(0), - &"any".into(), - &pred - )); + assert!(path_has_conflict(NodeIndex::new(2), NodeIndex::new(0), &"any".into(), &pred)); // No conflict: node 3 is not in path - assert!(!path_has_conflict( - NodeIndex::new(2), - NodeIndex::new(3), - &"any".into(), - &pred - )); + assert!(!path_has_conflict(NodeIndex::new(2), NodeIndex::new(3), &"any".into(), &pred)); // Self-check: node 2 is itself in the path - assert!(path_has_conflict( - NodeIndex::new(2), - NodeIndex::new(2), - &"any".into(), - &pred - )); + assert!(path_has_conflict(NodeIndex::new(2), NodeIndex::new(2), &"any".into(), &pred)); // Pool conflict: pool_a used - assert!(path_has_conflict( - NodeIndex::new(2), - NodeIndex::new(3), - &"pool_a".into(), - &pred - )); + assert!(path_has_conflict(NodeIndex::new(2), NodeIndex::new(3), &"pool_a".into(), &pred)); // Pool conflict: pool_b used - assert!(path_has_conflict( - NodeIndex::new(2), - NodeIndex::new(3), - &"pool_b".into(), - &pred - )); + assert!(path_has_conflict(NodeIndex::new(2), NodeIndex::new(3), &"pool_b".into(), &pred)); // No pool conflict: pool_c not used - assert!(!path_has_conflict( - NodeIndex::new(2), - NodeIndex::new(3), - &"pool_c".into(), - &pred - )); + assert!(!path_has_conflict(NodeIndex::new(2), NodeIndex::new(3), &"pool_c".into(), &pred)); } #[test] @@ -168,8 +138,7 @@ mod tests { pred[1] = Some((NodeIndex::new(0), "pool_a".into())); pred[2] = Some((NodeIndex::new(1), "pool_b".into())); - let path = - reconstruct_path(NodeIndex::new(2), NodeIndex::new(0), &pred).unwrap(); + let path = reconstruct_path(NodeIndex::new(2), NodeIndex::new(0), &pred).unwrap(); assert_eq!(path.len(), 2); assert_eq!(path[0], (NodeIndex::new(0), NodeIndex::new(1), "pool_a".into())); assert_eq!(path[1], (NodeIndex::new(1), NodeIndex::new(2), "pool_b".into())); diff --git a/fynd-core/src/algorithm/mod.rs b/fynd-core/src/algorithm/mod.rs index 81bdacf8..53c281a0 100644 --- a/fynd-core/src/algorithm/mod.rs +++ b/fynd-core/src/algorithm/mod.rs @@ -18,9 +18,9 @@ //! 2. Implement the `Algorithm` trait //! 3. Register it in `registry.rs` -pub(crate) mod bf_helpers; pub mod bellman_ford; pub mod bellman_ford_pricing; +pub(crate) mod bf_helpers; pub mod most_liquid; #[cfg(test)] diff --git a/fynd-core/src/derived/computations/token_gas_price.rs b/fynd-core/src/derived/computations/token_gas_price.rs index bf56d1ac..fe033e11 100644 --- a/fynd-core/src/derived/computations/token_gas_price.rs +++ b/fynd-core/src/derived/computations/token_gas_price.rs @@ -4,12 +4,12 @@ //! //! # Algorithm //! -//! 1. **BF Forward Pass (one-to-all)**: Run SPFA from gas_token with a probe amount. -//! Each token's distance = the best amount reachable via simulation during relaxation. -//! This replaces DFS path enumeration AND spot-price scoring in a single pass. +//! 1. **BF Forward Pass (one-to-all)**: Run SPFA from gas_token with a probe amount. Each token's +//! distance = the best amount reachable via simulation during relaxation. This replaces DFS path +//! enumeration AND spot-price scoring in a single pass. //! -//! 2. **Reverse Simulation**: For each priced token, reverse the winning path and simulate -//! the sell direction. Compute spread and mid_price from forward + reverse amounts. +//! 2. **Reverse Simulation**: For each priced token, reverse the winning path and simulate the sell +//! direction. Compute spread and mid_price from forward + reverse amounts. //! //! # Price Formulas //! @@ -162,11 +162,11 @@ impl TokenGasPriceComputation { // Compute mid_price in numerator/denominator form (precise BigUint arithmetic) let sell_out_net = &sell_out - &sell_gas_cost; // Safe: checked above let buy_price_precise = Price { - numerator: &buy_out * &sell_out_net - + &buy_out * (&self.simulation_amount + &buy_gas_cost), - denominator: BigUint::from(2u8) - * (&self.simulation_amount + &buy_gas_cost) - * sell_out_net, + numerator: &buy_out * &sell_out_net + + &buy_out * (&self.simulation_amount + &buy_gas_cost), + denominator: BigUint::from(2u8) * + (&self.simulation_amount + &buy_gas_cost) * + sell_out_net, }; Ok((spread, buy_price_precise, path_components)) diff --git a/fynd-core/src/derived/manager.rs b/fynd-core/src/derived/manager.rs index c127450a..7b3ad159 100644 --- a/fynd-core/src/derived/manager.rs +++ b/fynd-core/src/derived/manager.rs @@ -247,7 +247,8 @@ impl ComputationManager { /// /// **Dependency order**: /// 1. `SpotPriceComputation` - no dependencies - /// 2. `TokenGasPriceComputation` - depends on gas_price (uses BF SPFA, no spot_prices dependency) + /// 2. `TokenGasPriceComputation` - depends on gas_price (uses BF SPFA, no spot_prices + /// dependency) /// 3. `PoolDepthComputation` - depends on spot_prices in store async fn compute_all(&self, changed: &ChangedComponents) { let total_start = Instant::now(); @@ -372,9 +373,9 @@ impl MarketEventHandler for ComputationManager { added_components, removed_components, updated_components, - } if !added_components.is_empty() - || !removed_components.is_empty() - || !updated_components.is_empty() => + } if !added_components.is_empty() || + !removed_components.is_empty() || + !updated_components.is_empty() => { trace!( added = added_components.len(), diff --git a/fynd-core/src/derived/mod.rs b/fynd-core/src/derived/mod.rs index 555ff60a..22a5cebf 100644 --- a/fynd-core/src/derived/mod.rs +++ b/fynd-core/src/derived/mod.rs @@ -25,7 +25,8 @@ //! //! - **SpotPriceComputation**: No dependencies, computes spot prices for all pools //! - **PoolDepthComputation**: Depends on `spot_prices` -//! - **TokenGasPriceComputation**: Depends on `gas_price` (from market data); uses Bellman-Ford SPFA +//! - **TokenGasPriceComputation**: Depends on `gas_price` (from market data); uses Bellman-Ford +//! SPFA //! //! # Example //!