🔗 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 \(B \in P.\text{base\_currencies}\) and target \(T \in P.\text{target\_currencies}\) (with \(B \ne T\)):
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 \times | E | $ |
| Single + DFS inbound ✅ | $ | E | $ |
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 LR
subgraph ECB ["🇪🇺 ECB (base: EUR)"]
direction LR
end
subgraph FED ["🇺🇸 FED (base: USD)"]
direction LR
end
subgraph BOE ["🇬🇧 BOE (base: GBP)"]
direction LR
end
subgraph SNB ["🇨🇭 SNB (base: CHF)"]
direction LR
end
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.