01 / The idea
The better offer can arrive after the cost looked finished.
From USD, the bank’s direct quote into EUR costs 30 basis points, and its quote into GBP costs 12. A basis point is a hundredth of a percent, measured here against the mid-market rate. If the search fixed EUR at 30 now, it would miss a desk that sells euros for pounds 8 basis points better than mid: USD → GBP → EUR costs 12 − 8 = 4.
Bellman–Ford finds the cheapest path from one start through a directed, weighted graph, including edges that cost less than zero, as long as no loop the start can reach keeps lowering the total.
Instead of fixing one cost at a time, it repeats one plain step: ask every quote whether its currency can be reached more cheaply through the currency it converts from.
Relax every quote. Let better offers travel.
5 accepted updates in pass 1.
accepted now carried forward unreachable
Scan every quote, in the order they are listed.
Every currency starts at ∞ except USD at 0. The first pass sets EUR at 30 from the direct quote. GBP → EUR comes earlier in the list than USD → GBP, so it is skipped: GBP has no cost yet. GBP ends the pass at 12, and JPY at 60.
Reduced motion: choose a scene to see its completed state.
Read this scene
Every currency starts at ∞ except USD at 0. The first pass sets EUR at 30 from the direct quote. GBP → EUR comes earlier in the list than USD → GBP, so it is skipped: GBP has no cost yet. GBP ends the pass at 12, and JPY at 60.
Passes shown: 1. Accepted updates: JPY 60, EUR 30, GBP 12, CHF 45, SGD 65.
Watch and Step through replay the quotes pass by pass. Try it runs the same TypeScript implementation on those quotes or on the market with one stale quote.
The animation uses the real TypeScript trace. EUR drops from 30 to 4 on the second pass, and the improvement carries on to CHF, SGD, and JPY in the same pass. No quote leads to BRL, so it stays ∞.
02 / Name the rule
Relax a quote: carry the best offer forward.
Start USD at 0 and every other currency at ∞. For a quote u → v with cost w, compare cost[u] + w with cost[v]. Replace the
currency’s cost only when the offer is strictly smaller.
Source cost plus quote
A reached currency offers its current best cost, plus the quote, to the next one.
Only the smaller number
Every accepted offer replaces the predecessor too, so a chain can be rebuilt later.
One more time
After V − 1 passes, another improvement proves a reachable negative cycle.
The rule that holds is weaker than Dijkstra’s: after k passes, every chain of at most k quotes has been accounted for. A cheapest chain that doesn’t visit a currency twice uses at most V − 1 quotes. If a reachable loop can still lower a cost after that, no cheapest chain exists.
Why scan every quote again?The quote order is not a shortcut
In the example, GBP → EUR is listed before USD → GBP. On the first pass GBP has no cost yet when its quote is scanned, so EUR stays at 30. The second pass sees GBP at 12 and carries 4 onward. A different order might carry several improvements in one pass, but Bellman–Ford is correct in any order because it allows up to V − 1 full scans.
Why do basis points add up?The costs are logarithms
Each quote’s cost is 10,000 × ln(mid ÷ quoted rate), rounded to a whole basis point. Converting along a chain multiplies the rates, and logarithms turn multiplying into adding, so the cost of a chain is the sum of its quotes. At a few dozen basis points the logarithm and the plain percentage agree to the nearest point: a quote 0.3% worse than mid costs 30.
The mid-market rates come from one reference price per currency, so going round any loop at mid costs exactly 0. A loop costs less than nothing only when its quotes beat mid by more than they lose, and that is an arbitrage.
03 / Read the shape
Costs are a table, and each pass is a sweep.
Basic form validates the currencies and quotes, initializes the cost table,
and relaxes every quote. In the wild follows predecessor links to one
currency, and findArbitrage names the loop when the table is refused. At the call site prices USD to JPY, prints every cost, then adds one stale quote.
Both languages print the same four lines.
Validate a market of one-way quotes, then relax every quote up to V − 1 times. A strictly better offer replaces the old cost; no currency is ever treated as final.
export const MAX_CURRENCIES = 64;
export const MAX_QUOTES = 256;
export const MIN_COST = -500;
export const MAX_COST = 5000;
// cost is the basis points a quote loses against the mid-market rate:
// 10,000 × ln(mid ÷ quoted rate), rounded. A quote better than mid costs less than 0.
// Logarithms add, so the cost of a chain of conversions is the sum of its quotes.
export type Quote = { from: string; to: string; cost: number };
export type Market = { currencies: string[]; quotes: Quote[] };
export type Relaxation = {
from: string;
to: string;
oldCost: number | null;
newCost: number;
};
export type Pass = {
pass: number;
updates: Relaxation[];
costs: Record<string, number | null>;
changed: boolean;
};
export type Paths = {
start: string;
currencies: string[];
costs: Record<string, number | null>;
via: Record<string, string | null>;
passes: number;
};
export type ExchangeErrorCode =
'too-big' | 'bad-currency' | 'unknown-currency' | 'bad-cost' | 'negative-cycle';
export class ExchangeError extends Error {
readonly code: ExchangeErrorCode;
constructor(code: ExchangeErrorCode, message: string) {
super(message);
this.name = 'ExchangeError';
this.code = code;
}
}
function validate(market: Market, start: string): void {
if (market.currencies.length > MAX_CURRENCIES || market.quotes.length > MAX_QUOTES)
throw new ExchangeError(
'too-big',
`up to ${MAX_CURRENCIES} currencies and ${MAX_QUOTES} quotes`
);
const known = new Set<string>();
for (const currency of market.currencies) {
if (!/^[a-z]{3}$/.test(currency) || known.has(currency))
throw new ExchangeError(
'bad-currency',
`currencies are unique three-letter lowercase codes: ${currency}`
);
known.add(currency);
}
for (const quote of market.quotes) {
if (!known.has(quote.from) || !known.has(quote.to))
throw new ExchangeError(
'unknown-currency',
`a quote names a currency that is not in the market: ${quote.from}, ${quote.to}`
);
if (!Number.isInteger(quote.cost) || quote.cost < MIN_COST || quote.cost > MAX_COST)
throw new ExchangeError(
'bad-cost',
`costs are whole basis points from ${MIN_COST} to ${MAX_COST}`
);
}
if (!known.has(start)) throw new ExchangeError('unknown-currency', `no such currency: ${start}`);
}
function snapshot(currencies: string[], costs: Map<string, number>): Record<string, number | null> {
return Object.fromEntries(currencies.map((currency) => [currency, costs.get(currency) ?? null]));
}
// Relax every quote up to V - 1 times. In-place updates let an improvement travel on within
// the same pass, but no currency's cost is ever treated as final.
export function traceRelaxation(market: Market, start: string): Pass[] {
validate(market, start);
const costs = new Map<string, number>([[start, 0]]);
const passes: Pass[] = [];
for (let pass = 1; pass < market.currencies.length; pass++) {
const updates: Relaxation[] = [];
for (const quote of market.quotes) {
const from = costs.get(quote.from);
if (from === undefined) continue;
const newCost = from + quote.cost;
const oldCost = costs.get(quote.to) ?? null;
if (oldCost === null || newCost < oldCost) {
costs.set(quote.to, newCost);
updates.push({ from: quote.from, to: quote.to, oldCost, newCost });
}
}
passes.push({
pass,
updates,
costs: snapshot(market.currencies, costs),
changed: updates.length > 0
});
if (updates.length === 0) break;
}
for (const quote of market.quotes) {
const from = costs.get(quote.from);
if (from !== undefined && from + quote.cost < (costs.get(quote.to) ?? Infinity))
throw new ExchangeError('negative-cycle', 'a reachable negative cycle has no cheapest chain');
}
return passes;
}
export function shortestPaths(market: Market, start: string): Paths {
const passes = traceRelaxation(market, start);
const last = passes.at(-1);
const costs = last?.costs ?? snapshot(market.currencies, new Map([[start, 0]]));
const via: Record<string, string | null> = Object.fromEntries(
market.currencies.map((currency) => [currency, null])
);
for (const pass of passes)
for (const update of pass.updates) {
via[update.to] = update.from;
}
return {
start,
currencies: market.currencies.slice(),
costs,
via,
passes: passes.length
};
} const (
MaxCurrencies = 64
MaxQuotes = 256
MinCost = -500
MaxCost = 5000
)
// Cost is the basis points a quote loses against the mid-market rate:
// 10,000 × ln(mid ÷ quoted rate), rounded. A quote better than mid costs less than 0.
// Logarithms add, so the cost of a chain of conversions is the sum of its quotes.
type Quote struct {
From string
To string
Cost int
}
type Market struct {
Currencies []string
Quotes []Quote
}
type Relaxation struct {
From string
To string
OldCost *int
NewCost int
}
type Pass struct {
Pass int
Updates []Relaxation
Costs map[string]*int
Changed bool
}
type Paths struct {
Start string
Currencies []string
Costs map[string]*int
Via map[string]string
Passes int
}
type ExchangeError struct {
Code string
Message string
}
func (e *ExchangeError) Error() string { return e.Message }
var currencyPattern = regexp.MustCompile(`^[a-z]{3}$`)
func validate(market Market, start string) error {
if len(market.Currencies) > MaxCurrencies || len(market.Quotes) > MaxQuotes {
return &ExchangeError{"too-big", fmt.Sprintf("up to %d currencies and %d quotes", MaxCurrencies, MaxQuotes)}
}
known := map[string]bool{}
for _, currency := range market.Currencies {
if !currencyPattern.MatchString(currency) || known[currency] {
return &ExchangeError{"bad-currency", "currencies are unique three-letter lowercase codes: " + currency}
}
known[currency] = true
}
for _, quote := range market.Quotes {
if !known[quote.From] || !known[quote.To] {
return &ExchangeError{"unknown-currency", fmt.Sprintf("a quote names a currency that is not in the market: %s, %s", quote.From, quote.To)}
}
if quote.Cost < MinCost || quote.Cost > MaxCost {
return &ExchangeError{"bad-cost", fmt.Sprintf("costs are whole basis points from %d to %d", MinCost, MaxCost)}
}
}
if !known[start] {
return &ExchangeError{"unknown-currency", "no such currency: " + start}
}
return nil
}
func snapshot(currencies []string, costs map[string]int) map[string]*int {
result := map[string]*int{}
for _, currency := range currencies {
if value, ok := costs[currency]; ok {
copy := value
result[currency] = ©
} else {
result[currency] = nil
}
}
return result
}
// Relax every quote up to V - 1 times. In-place updates let an improvement travel on within
// the same pass, but no currency's cost is ever treated as final.
func TraceRelaxation(market Market, start string) ([]Pass, error) {
if err := validate(market, start); err != nil {
return nil, err
}
costs := map[string]int{start: 0}
passes := []Pass{}
for pass := 1; pass < len(market.Currencies); pass++ {
updates := []Relaxation{}
for _, quote := range market.Quotes {
from, ok := costs[quote.From]
if !ok {
continue
}
newCost := from + quote.Cost
oldCost, reached := costs[quote.To]
if !reached || newCost < oldCost {
var old *int
if reached {
copy := oldCost
old = ©
}
costs[quote.To] = newCost
updates = append(updates, Relaxation{quote.From, quote.To, old, newCost})
}
}
passes = append(passes, Pass{pass, updates, snapshot(market.Currencies, costs), len(updates) > 0})
if len(updates) == 0 {
break
}
}
for _, quote := range market.Quotes {
from, fromReached := costs[quote.From]
to, toReached := costs[quote.To]
if fromReached && (!toReached || from+quote.Cost < to) {
return nil, &ExchangeError{"negative-cycle", "a reachable negative cycle has no cheapest chain"}
}
}
return passes, nil
}
func ShortestPaths(market Market, start string) (Paths, error) {
passes, err := TraceRelaxation(market, start)
if err != nil {
return Paths{}, err
}
costs := map[string]*int{}
if len(passes) > 0 {
costs = passes[len(passes)-1].Costs
} else {
costs = snapshot(market.Currencies, map[string]int{start: 0})
}
via := map[string]string{}
for _, currency := range market.Currencies {
via[currency] = ""
}
for _, pass := range passes {
for _, update := range pass.Updates {
via[update.To] = update.From
}
}
return Paths{start, append([]string(nil), market.Currencies...), costs, via, len(passes)}, nil
} Reading the TypeScriptMaps, snapshots, and a strict improvement
costs is a Map whose missing entries mean ∞. Each accepted
relaxation writes via so the chain can be rebuilt. traceRelaxation records a snapshot after every pass for the animation and
lab, while shortestPaths returns the final table. findArbitrage keeps the quote that last improved each currency, so it can walk
back round the loop.
Reading the GoMaps and pointers for unreached currencies
Go stores reached costs as integers in a map and uses a nil pointer in snapshots for ∞. CheapestChain and FindArbitrage return a nil pointer and no error
when there is nothing to return. The quote loop is the same: compare, replace, remember the
predecessor, then run the extra negative-cycle check.
What is refusedBounds and graph safety
Both versions bound the market at 64 currencies and 256 quotes, require unique
three-letter lowercase codes (bad-currency), check quote endpoints (unknown-currency) before costs, and accept whole basis points from −500 to 5,000 (bad-cost). A reachable negative cycle is negative-cycle; a loop the start can’t reach doesn’t change its costs.
04 / Try a decision
What does one more improvement mean?
The extra pass isn’t an optimization detail. It is the line between a cost that is high but real and a loop that pays you every time you go round it.
05 / Follow the cost
Certainty costs a full sweep per possible quote.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Relax one quote | O(1) | O(1) | Read one quote and replace its currency’s cost only when the new offer is smaller. |
| Run one full pass | O(E) | O(V + E) | Scan all E one-way quotes once. The shown trace also keeps a V-entry snapshot and the accepted updates for the pass; without the trace the pass needs O(1) extra. |
| Find every cheapest chain | O(VE) | O(V² + VE) | At most V − 1 passes are needed when no reachable negative cycle exists. The shown trace keeps every pass; keeping only the current table needs O(V). |
| Stop after no changes | O(kE) | O(k(V + E)) | k is the number of useful passes; it can be much smaller than V − 1. Without the trace, O(V). |
| Rebuild one chain | O(V) | O(V) | Follow predecessor links from the target back to the start, then reverse them. |
| Name the loop | O(VE) | O(V) | findArbitrage runs V passes with no trace, then takes at most 2V steps back along predecessor links. |
With V currencies and E quotes, the worst case is O(VE): one pass for each quote a cheapest
chain might need. Sedgewick and Wayne put it as “time proportional to E V.” The algorithm
itself needs O(V) for costs and predecessors, plus the input. The shown traceRelaxation also keeps a snapshot and an update log for every pass, O(V² + VE)
in the worst case, because the animation and lab replay it; a production version keeps only the
current table.
That cost buys something Dijkstra doesn’t have: edges below zero. If every cost is zero or more, Dijkstra’s priority queue usually does less work by making a safe greedy choice. Bellman–Ford is the slower, more general method when that choice isn’t safe.
06 / Give it a real job
Refuse to quote a loop, and name it.
A payments service that pays suppliers abroad asks this every time its quotes refresh: what
is the cheapest chain from the currency we hold to the one we owe? cheapestChain answers from the finished table. USD to JPY is 49 bp through GBP, EUR, CHF, and SGD, cheaper than
the bank’s direct 60.
Now one desk’s EUR → USD price goes stale, 20 bp better than mid after the euro has moved.
The loop USD → GBP → EUR → USD costs 12 − 8 − 20 = −16 bp: a million dollars sent round it
would come back as about $1,001,600, and every lap would add more. The table has no cheapest
chain, so the service refuses to quote, and findArbitrage names the three quotes
for someone to check. On a live market a loop like that disappears as soon as anyone trades on
it, so when your own service finds one, it usually means bad data.
The same relaxation, run by many machines at once, is how older routers learn their routes. RFC 2453 says it plainly: “RIP is a routing protocol based on the Bellman-Ford (or distance vector) algorithm.” Each router offers its neighbors its current distances, and a neighbor keeps an offer only when it beats what it has.
This runs on the server that holds the quote table; nothing in a component computes it.
07 / Make the call
Choose based on edge weights and the question.
Use Bellman–Ford for one start when edges below zero are meaningful and a reachable negative cycle should be reported. Use Dijkstra when every weight is zero or more and you want the faster priority-queue search. If every edge has the same cost, BFS is simpler; if you need the cost between every pair of currencies, not only from one, Floyd–Warshall fills the whole table at once.
Bellman–Ford answers cheapest paths, not “best” paths through a profitable loop. A negative cycle is a signal to fix the data, cap the number of conversions, or ask a different question.
SourcesThe paper, a textbook, and an RFC
- Richard Bellman, “On a routing problem”, Quarterly of Applied Mathematics 16(1), 1958, 87–90.
- Sedgewick and Wayne, Algorithms, 4th edition, section 4.4: arbitrage detection (“replace each weight by its logarithm, negated”) and the E V bound.
- RFC 2453, RIP Version 2, section 3.1.
All checked 23 September 2026.
08 / Take the idea with you
Explain a cheapest chain without saying “Bellman–Ford.”
“Start at zero and everything else at infinity. Go through every conversion and keep any cheaper offer it makes. Do that once for each currency but one. If a conversion can still make something cheaper after that, there’s a loop that pays you to go round it, so there is no cheapest answer.”
Before moving on, think of a graph you know where an edge could honestly be negative, and ask whether a loop in it could be too.
Connections to follow nextRelated lessons
- Dijkstra’s shortest path is faster when no edge is negative, and shows why a negative edge breaks it.
- Floyd–Warshall all-pairs shortest paths answers every pair at once and spots a negative cycle on its diagonal.
- Graph overview names directed edges and weighted paths.
- Dynamic programming is the same “reuse a bounded state” instinct; here the state is a currency and a pass count.