๐ FX Conversion Chain โ Pathfinding Algorithm
Status: Implemented (Mar 2026)
๐ Overview
LibreFolio allows users to convert between any two currencies, even when no single provider covers the pair directly. The frontend builds a currency graph and runs a DFS (Depth-First Search) with backtracking to discover all possible conversion routes โ both direct (1-step) and multi-step (chain).
The algorithm lives in two modules:
| Module | Purpose |
|---|---|
src/lib/utils/currencyGraph.ts |
Graph construction + DFS pathfinding |
src/lib/stores/currencyGraphStore.ts |
Session-level caching + public API |
๐ The Currency Graph
The graph is a MultiDirectedGraph (from the graphology library):
- Nodes = all ISO 4217 currency codes (~180), from
GET /utilities/currencies - Edges = provider capabilities, from
GET /fx/providers
๐ Edge Construction
For each provider P (excluding MANUAL):
- For each base
BinP.base_currencies - And target
TinP.target_currencies(withB != T)
Add Directed Edge: B โ T with payload {provider: P.code}
Key design decisions:
- Single directed edges โ one edge \(B \to T\) per (provider, base, target). No reverse edge \(T \to B\) is added.
- Bidirectionality via DFS โ the DFS explores both outbound edges (\(B \to T\)) and inbound edges (\(T \leftarrow B\)) at each node, effectively traversing in reverse.
- Multi-graph โ if two providers both cover ๐ช๐บ EURโ๐บ๐ธ USD, there are two parallel edges with different
providerattributes.
โ Why Single-Direction Edges?
| Approach | Edges | Pros | Cons |
|---|---|---|---|
| Bidirectional (2 edges per pair) | 2 * E |
Simple DFS (outbound only) | Doubled memory, unclear semantics |
| Single + DFS inbound โ | E |
Clear semantics, compact | DFS must explore 2 directions |
The single-direction approach encodes the real relationship: "Provider P publishes rates from B to T". The backend's compute_chain_rate() uses alphabetical normalization to decide whether to use the rate directly or invert it (\(1/\text{rate}\)).
๐ Simplified Example Graph
Consider 4 providers and 5 currencies:
graph TD
subgraph Providers
ECB["๐ช๐บ ECB (EUR)"]
FED["๐บ๐ธ FED (USD)"]
BOE["๐ฌ๐ง BOE (GBP)"]
SNB["๐จ๐ญ SNB (CHF)"]
ECB ~~~ BOE
FED ~~~ SNB
end
subgraph Currencies
EUR["๐ช๐บ EUR"]
USD["๐บ๐ธ USD"]
GBP["๐ฌ๐ง GBP"]
CHF["๐จ๐ญ CHF"]
RON["๐ท๐ด RON"]
end
Providers ~~~ Currencies
EUR -->|ECB| USD
EUR -->|ECB| GBP
EUR -->|ECB| CHF
EUR -->|ECB| RON
USD -->|FED| EUR
USD -->|FED| GBP
USD -->|FED| CHF
GBP -->|BOE| USD
GBP -->|BOE| EUR
CHF -->|SNB| EUR
Each arrow is a directed edge with the provider label. The DFS can traverse any edge in reverse (e.g., go from ๐บ๐ธ USD to ๐ช๐บ EUR via the ECB edge ๐ช๐บ EURโ๐บ๐ธ USD).
๐ Example: All Routes from ๐ท๐ด RON to ๐บ๐ธ USD
Starting from ๐ท๐ด RON, the DFS finds these paths:
| # | Path | Steps | Providers |
|---|---|---|---|
| 1 | ๐ท๐ด RON โ[ECB]โ ๐ช๐บ EUR โ[ECB]โ ๐บ๐ธ USD |
2 | ECB ร 2 |
| 2 | ๐ท๐ด RON โ[ECB]โ ๐ช๐บ EUR โ[FED]โ ๐บ๐ธ USD |
2 | ECB + FED |
| 3 | ๐ท๐ด RON โ[ECB]โ ๐ช๐บ EUR โ[BOE]โ ๐ฌ๐ง GBP โ[BOE]โ ๐บ๐ธ USD |
3 | ECB + BOE ร 2 |
| 4 | ๐ท๐ด RON โ[ECB]โ ๐ช๐บ EUR โ[SNB]โ ๐จ๐ญ CHF โ[FED]โ ๐บ๐ธ USD |
3 | ECB + SNB + FED |
| 5 | ๐ท๐ด RON โ[ECB]โ ๐ช๐บ EUR โ[FED]โ ๐ฌ๐ง GBP โ[BOE]โ ๐บ๐ธ USD |
3 | ECB + FED + BOE |
| ... | (other 3-4 step combinations) | 3-4 | ... |
Route 1 is the shortest (2 steps) and uses only ECB. Route 2 mixes ECB and FED. Longer chains provide more fallback options but carry higher failure risk.
Chain Failure Risk
If any single step in a chain fails (provider down, API error), the entire chain fails. Shorter chains are more reliable.
๐ DFS Algorithm
๐ Pseudocode
function findAllPaths(graph, source, target, maxDepth=4):
validPaths = []
function dfs(currentNode, pathEdges, visitedNodes, providerUseCount):
if currentNode == target:
validPaths.append(copy(pathEdges))
return
if len(pathEdges) >= maxDepth:
return
function tryEdge(neighbor, provider):
if neighbor โ visitedNodes: โ Constraint 1
return
if providerUseCount[provider] >= 2: โ Constraint 2
return
// ADVANCE
pathEdges.push({from: currentNode, to: neighbor, provider})
visitedNodes.add(neighbor)
providerUseCount[provider] += 1
dfs(neighbor, pathEdges, visitedNodes, providerUseCount)
// BACKTRACK
pathEdges.pop()
visitedNodes.remove(neighbor)
providerUseCount[provider] -= 1
// Explore outbound edges (native direction)
for each edge (currentNode โ T) with provider P:
tryEdge(T, P)
// Explore inbound edges (reverse direction)
for each edge (S โ currentNode) with provider P:
tryEdge(S, P)
dfs(source, [], {source}, {})
return sort(validPaths, by=length)
๐ Constraints
The DFS enforces two constraints at each step to produce valid, non-redundant routes:
๐ Constraint 1 โ Simple Paths (No Repeated Nodes)
Each node (currency) can appear at most once in a path. This eliminates:
- Trivial cycles: A โ B โ A (returning to the same node)
- Redundant round-trips: ๐ช๐บ EUR โ ๐บ๐ธ USD โ ๐ฌ๐ง GBP โ ๐ช๐บ EUR โ ๐ท๐ด RON (the ๐ช๐บ EURโ๐บ๐ธ USDโ๐ฌ๐ง GBPโ๐ช๐บ EUR detour is pointless and introduces unnecessary failure risk)
The source node is added to visitedNodes at initialization, so the DFS can never return to it. The target node is not in visitedNodes โ it serves as the exit condition.
Evolution
The original algorithm used usedEdgePairs: Set<string> (tracking visited edges, not nodes). This allowed the same node to appear multiple times if reached via different edge pairs, producing redundant cycles like ๐ช๐บ EURโ๐บ๐ธ USDโ๐ฌ๐ง GBPโ๐ช๐บ EURโ๐ท๐ด RON. Switching to visitedNodes (simple paths) eliminated these and guarantees each conversion chain is unique and optimal.
2๏ธโฃ Constraint 2 โ Max 2 Uses per Provider
A provider can be used at most twice in the same path. This reflects the real-world topology:
- A provider like ECB has one base (๐ช๐บ EUR) and many targets (๐บ๐ธ USD, ๐ฌ๐ง GBP, ๐จ๐ญ CHF, ๐ท๐ด RON, ...).
- To "bridge through" ๐ช๐บ EUR using ECB, you need two edges: one to reach ๐ช๐บ EUR, one to leave ๐ช๐บ EUR.
Example:
๐ท๐ด RON โ[ECB]โ ๐ช๐บ EUR โ[ECB]โ ๐บ๐ธ USD(2 uses of ECB). - A third ECB edge would be redundant: you'd have already left the ECB hub.
Backtracking
The DFS uses mutable state with explicit undo (backtracking):
// ADVANCE โ modify state
pathEdges.push(step)
visitedNodes.add(neighbor)
providerUseCount[provider] += 1
// RECURSE โ explore from neighbor
dfs(neighbor, ...)
// BACKTRACK โ undo state
pathEdges.pop()
visitedNodes.remove(neighbor)
providerUseCount[provider] -= 1
This pattern is memory-efficient: only one path is in the stack at a time, regardless of how many paths exist. Each path is copied to validPaths only when the target is reached.
Bidirectional Traversal
At each node, the DFS explores edges in both directions:
// OUTBOUND: currentNode is the edge source (native direction)
// Edge: currentNode โ neighbor | rate used as-is
graph.forEachOutboundEdge(currentNode, (_, attrs, _, tgt) => {
tryEdge(tgt, attrs.provider);
});
// INBOUND: currentNode is the edge target (reverse direction)
// Edge: neighbor โ currentNode | rate inverted (1/rate) by backend
graph.forEachInboundEdge(currentNode, (_, attrs, src, _) => {
tryEdge(src, attrs.provider);
});
The ChainStep always records the logical direction: { from: currentNode, to: neighbor }. The backend determines whether to use the rate directly or invert it via alphabetical normalization in compute_chain_rate().
Visualization: DFS Tree for ๐ท๐ด RON โ ๐บ๐ธ USD
graph TD
START["๐ข ๐ท๐ด RON (start)"] --> E1["๐ช๐บ EUR via ECB"]
E1 --> U1["โ
๐บ๐ธ USD via ECB<br/><i>Path 1: ๐ท๐ด RONโ๐ช๐บ EURโ๐บ๐ธ USD</i>"]
E1 --> G1["๐ฌ๐ง GBP via ECB"]
E1 --> C1["๐จ๐ญ CHF via ECB"]
E1 --> U2["โ
๐บ๐ธ USD via FED<br/><i>Path 2: ๐ท๐ด RONโ๐ช๐บ EURโ๐บ๐ธ USD</i>"]
E1 --> G2["๐ฌ๐ง GBP via FED"]
E1 --> C2["๐จ๐ญ CHF via FED"]
E1 --> C3["๐จ๐ญ CHF via SNB"]
G1 --> U3["โ
๐บ๐ธ USD via BOE<br/><i>Path 3: ๐ท๐ด RONโ๐ช๐บ EURโ๐ฌ๐ง GBPโ๐บ๐ธ USD</i>"]
G2 --> U4["โ
๐บ๐ธ USD via BOE<br/><i>Path 4: ๐ท๐ด RONโ๐ช๐บ EURโ๐ฌ๐ง GBPโ๐บ๐ธ USD</i>"]
C1 --> DEAD1["โ dead end<br/>(no edge to ๐บ๐ธ USD)"]
C2 --> DEAD2["โ dead end"]
C3 --> DEAD3["โ dead end"]
style START fill:#22c55e,color:white
style U1 fill:#3b82f6,color:white
style U2 fill:#3b82f6,color:white
style U3 fill:#3b82f6,color:white
style U4 fill:#3b82f6,color:white
style DEAD1 fill:#ef4444,color:white
style DEAD2 fill:#ef4444,color:white
style DEAD3 fill:#ef4444,color:white
Pruning
Branches are pruned early: if a neighbor is in visitedNodes or the provider is at max usage, tryEdge returns immediately without recursing.
Complexity
With \(N\) = number of currencies, \(E\) = number of edges, \(D\) = maxDepth:
| Metric | Value | Notes |
|---|---|---|
| Time (worst case) | \(O(E^D)\) | Exponential in depth, but \(D=4\) is small |
| Time (practical) | < 1ms | ~4 providers ร ~25 currencies, ~200 edges |
| Space | \(O(D)\) | Path stack depth (recursive DFS) |
| Output | Sorted by length | Shortest paths first |
The graph is small enough that Web Worker parallelization is not needed. Profiling shows sub-millisecond execution even with all providers enabled.
Session-Level Caching
The graph is built once per browser session and cached in currencyGraphStore.ts:
// Module-level singleton
let cachedGraph: MultiDirectedGraph | null = null;
export async function getOrBuildGraph(): MultiDirectedGraph {
if (cachedGraph) return cachedGraph;
const [providers, currencies] = await Promise.all([
fetchProviders(),
fetchCurrencies(),
]);
cachedGraph = buildCurrencyGraph(providers, currencies);
return cachedGraph;
}
Why this works: provider capabilities (GET /fx/providers) and currency lists (GET /utilities/currencies) are constant for the entire server session โ they only change on server restart. No cache invalidation is needed.
Route Sorting in the UI
When multiple paths are found, FxProviderSelect.svelte sorts them:
- Direct routes (1-step) appear first in their own section
- Chain routes are grouped by step count (2-step, 3-step, 4-step)
- Within each group, chains are sorted by:
- Configured intermediate legs (descending) โ chains that reuse already-registered pairs appear first
- Unique provider count (ascending) โ fewer providers = simpler
- Key (alphabetical tiebreaker)
Output Format
Each path is a ChainStep[] array, directly usable as chain_steps in POST /fx/providers/routes:
interface ChainStep {
from: string; // Source currency (logical direction)
to: string; // Target currency (logical direction)
provider: string; // Provider code (e.g., "ECB")
}
// Direct route
[{ from: "EUR", to: "USD", provider: "ECB" }]
// 2-step chain
[
{ from: "RON", to: "EUR", provider: "ECB" },
{ from: "EUR", to: "USD", provider: "ECB" }
]
No transformation is needed between the DFS output and the API request body.