Built-in exchange-rate providers
Bodu ships eleven exchange-rate providers, one per published source. Each is a thin
fetcher that downloads and parses its source and serves the result through the
same IDatedRateProvider
and timeless IRateProvider
contracts - so any provider drops into the same lookups, the same
caching and aggregation layer, and the same
Money conversions as every other. None of them knows anything about
caching; that is added in front (see the caching guide).
The providers at a glance
| Provider | Package | Base | Source format | History depth | DI registration |
|---|---|---|---|---|---|
| Reserve Bank of Australia | Bodu.Financial.ExchangeRates.Rba |
AUD | published .xls workbooks, per era |
since 1983‑01‑01 (first era) | AddRbaExchangeRates() |
| European Central Bank | Bodu.Financial.ExchangeRates.Ecb |
EUR | the eurofxref XML feed |
since 1999‑01‑04 (euro epoch) | AddEcbExchangeRates() |
| Bank of England | Bodu.Financial.ExchangeRates.Boe |
GBP | CSV over a date window | since 1975‑01‑02 (daily spot inception) | AddBoeExchangeRates() |
| Yahoo Finance | Bodu.Financial.ExchangeRates.Yahoo |
any pair | per-ticker chart JSON | since 2003‑12‑01 (chart inception) | AddYahooExchangeRates() |
| OFX (ofx.com) | Bodu.Financial.ExchangeRates.Ofx |
any pair | per-pair spot-rate-history JSON | unbounded (multi-decade, no published floor) | AddOfxExchangeRates() |
| XE.com | Bodu.Financial.ExchangeRates.Xe |
any pair | per-pair charting-rates JSON | rolling ~10 years (server-determined, estimated) | AddXeExchangeRates() |
| OANDA | Bodu.Financial.ExchangeRates.Oanda |
any pair | per-pair rate-history JSON | rolling ~180 days | AddOandaExchangeRates() |
| Fixer (fixer.io) | Bodu.Financial.ExchangeRates.Fixer |
any pair | per-pair time-series / single-date JSON (access_key) |
since 1999‑01‑01 | AddFixerExchangeRates() |
| exchangerate.host | Bodu.Financial.ExchangeRates.ExchangeRateHost |
any pair | per-pair time-series / single-date JSON (access_key) |
since 1999‑01‑01 | AddExchangeRateHostExchangeRates() |
| FRED (St. Louis Fed) | Bodu.Financial.ExchangeRates.Fred |
mapped pairs | per-pair series_id observations JSON (api_key) |
unbounded (per series) | AddFredExchangeRates() |
| IMF | Bodu.Financial.ExchangeRates.Imf |
USD | monthly representative-rates TSV report (keyless, daily) | unbounded | AddImfExchangeRates() |
RBA, ECB, BoE, and IMF quote one base currency against many others (AUD, EUR, GBP,
and USD respectively); direct (BASE→X) and inverse (X→BASE) lookups are supported,
cross pairs are not. Yahoo, OFX, XE, OANDA, Fixer, and exchangerate.host fetch a
distinct series per pair, so they serve arbitrary pairs directly (subject to their
plan's base-currency rules). FRED is per-pair too, but each pair must be mapped to a
FRED series identifier - it ships a built-in map for the major pairs and accepts more
through its options. Fixer, exchangerate.host, and FRED require an API key on their
options; IMF is keyless.
Every provider advertises its history depth through
HistoryAvailability,
so a caller can resolve the earliest date worth requesting before issuing a
lookup. The value is advisory - it describes the source's published coverage,
not a per-day or per-series guarantee: BoE and Yahoo floors reflect their
longest-running series (later-inception series exist), ECB's floor follows the
configured feeds (rolling when the full-history feed is excluded), RBA's
follows the configured era catalogue, and XE's window is an estimate of a
server-determined range. The pair providers expose the value as a settable
option; BoE adds its own options property for the same purpose.
The value is also discoverable at runtime through the
IHistoricalRateProvider
capability interface, and the caching and aggregation decorators consume it by
default: fetches for declared-unavailable dates are skipped or clamped rather
than issued. See
Respecting advertised history
in the caching guide.
What every provider shares
Because the surface is uniform, the same code drives any provider - the only difference is the type you construct and its options.
Two construction styles. The options-only constructor builds and owns an
HttpClient; dispose the provider to release
it. The constructor that takes an HttpClient uses the caller's client as-is and
never disposes it - the form the dependency-injection registration uses, backed by
IHttpClientFactory.
using Bodu.Financial;
using Bodu.Financial.ExchangeRates;
// The provider owns the HttpClient it builds from the options; dispose it to release the client.
using var provider = new RbaRateProvider(new RbaRateProviderOptions());
Warm, then look up. A provider loads its source on demand. Warm the in-memory
store first with LoadRangeAsync (or the provider's preload method), then resolve
synchronously:
await provider.LoadRangeAsync(new DateOnly(2023, 1, 1), new DateOnly(2026, 6, 30));
RateLookupResult usd = provider.GetRate("AUD", "USD", new DateOnly(2023, 1, 3));
// usd.Rate.Rate, usd.Rate.Provider == "RBA", usd.Provenance.Origin == RateOrigin.Live
A synchronous lookup that misses an unloaded span blocks to download it only when
AllowSynchronousNetworkAccess is set to true; it defaults to false, which
keeps callers on the asynchronous surface or an explicit preload. Concurrent
loads of the same span are coalesced, so a burst of misses triggers at most one
download.
A common warm-up surface across every provider. Each provider also carries
source-specific warm-up methods shaped to its feed (LoadRangeAsync and
PreloadAsync for the bulk feeds; LoadPairAsync for the pair feeds). On top of
those, every provider implements
IPairRateLoader - LoadPairAsync(from, to, start, end)
and GetLoadedPairs() - so a consumer can warm a pair's window and enumerate the
loaded pairs uniformly without knowing whether the source fetches by pair, era,
feed, or range. On a single-base feed such as RBA the pair must involve its base
currency (for example AUD); an unsupported pair is rejected before any download.
IPairRateLoader loader = provider;
await loader.LoadPairAsync("AUD", "USD", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
foreach (CurrencyPair pair in loader.GetLoadedPairs())
{
// pair.From, pair.To
}
Lookups behave identically across providers. Dated and timeless lookups,
TryGetRate, range reads, date-resolution policies, and inverse fallback all work
the same way described in Working with exchange rates:
// Dated, with a fallback policy and the resolution metadata.
provider.TryGetRate("AUD", "USD", new DateOnly(2024, 1, 6),
RateLookupOptions.PreviousWithin(7), out RateLookupResult prev);
// A whole window at once (AUD-based pairs; the reverse direction is inverted).
RateRangeResult series =
await provider.GetRatesAsync("AUD", "JPY", new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 12));
// The timeless surface resolves the most recent rate.
decimal latest = ((IRateProvider)provider).GetRate("AUD", "USD");
Logging is opt-in and free when unused. Pass an
ILogger (or let the DI package wire
one); with no logger the provider uses NullLogger. Each provider's options expose
per-event *LogLevel properties, defaulting to one Information line per
completed download, Debug for download starts, and Trace for per-observation
detail.
Downloaded payloads are cached on disk. The bulk providers (ECB, BoE, RBA, IMF)
keep a best-effort cache of the raw bytes they downloaded through EnableDiskCache
(on by default for ECB, BoE, and IMF; off for RBA), so immutable history is not
re-fetched; the pair providers do not cache payloads. This is distinct from the rate cache:
the on-disk payload cache avoids re-downloading the source file, while the rate
cache stores parsed, resolved rates in front of the provider.
Shared options. The pair-provider options - Yahoo, OFX, XE, OANDA, Fixer,
exchangerate.host, and FRED - plus the IMF options derive from the abstract
WebRateProviderOptions, so they share its surface - the
BaseAddress, HttpTimeout (default 30s), UserAgent, AllowSynchronousNetworkAccess,
DefaultLookback (default 7 days), a CurrencyAliases map for non-ISO source symbols,
and the per-event *LogLevel knobs. RBA, ECB, and BoE carry their own option types
(RbaRateProviderOptions and friends) with source-specific settings such as the RBA's
era list. The DI registration's configureResilience parameter tunes the standard
Polly handler (HttpStandardResilienceOptions) wrapped around the named HttpClient.
Reserve Bank of Australia (AUD)
RbaRateProvider
serves the RBA's published historical daily rates. The RBA splits its history into
eras, each a published .xls workbook covering a span of dates; a range load
fetches every era overlapping the request. Configure the eras, base URL, timeout,
user agent, and disk cache through
RbaRateProviderOptions;
warm the store with PreloadAsync, LoadEraAsync, or LoadRangeAsync.
using Bodu.Financial.ExchangeRates;
using var rba = new RbaRateProvider(new RbaRateProviderOptions());
await rba.LoadRangeAsync(new DateOnly(2023, 1, 1), new DateOnly(2026, 6, 30));
RateLookupResult aud = rba.GetRate("AUD", "USD", new DateOnly(2023, 1, 3));
foreach (RbaSeriesInfo info in rba.GetAvailablePairs())
Console.WriteLine($"{info.Pair.From}/{info.Pair.To} ({info.SeriesId})");
European Central Bank (EUR)
EcbRateProvider
serves the ECB euro foreign-exchange reference rates from the eurofxref XML feed.
The feed carries the full published history, so one load covers every date it
contains. Options are
EcbRateProviderOptions.
using Bodu.Financial.ExchangeRates;
using var ecb = new EcbRateProvider(new EcbRateProviderOptions());
await ecb.LoadRangeAsync(new DateOnly(2023, 1, 1), new DateOnly(2023, 12, 31));
RateLookupResult usd = ecb.GetRate("EUR", "USD", new DateOnly(2023, 1, 3));
RateLookupResult inverse = ecb.GetRate("USD", "EUR", new DateOnly(2023, 1, 3)); // inverted
Bank of England (GBP)
BoeRateProvider
serves the Bank of England daily spot rates, downloaded as CSV over a requested
date window. A synchronous miss loads a window around the requested date;
LoadRangeAsync warms an explicit range. Options are
BoeRateProviderOptions.
using Bodu.Financial.ExchangeRates;
using var boe = new BoeRateProvider(new BoeRateProviderOptions());
await boe.LoadRangeAsync(new DateOnly(2023, 1, 1), new DateOnly(2023, 12, 31));
RateLookupResult gbp = boe.GetRate("GBP", "USD", new DateOnly(2023, 1, 3));
Yahoo Finance (any pair)
YahooRateProvider
fetches a Yahoo Finance chart per currency pair (the ticker AUDUSD=X for AUD/USD),
so unlike the central-bank providers it serves arbitrary pairs rather than one base
currency. Warm a pair over a window with LoadPairAsync. Options are
YahooRateProviderOptions.
using Bodu.Financial.ExchangeRates;
using var yahoo = new YahooRateProvider(new YahooRateProviderOptions());
await yahoo.LoadPairAsync("AUD", "USD", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
RateLookupResult aud = yahoo.GetRate("AUD", "USD", new DateOnly(2023, 1, 3));
OFX (any pair)
OfxRateProvider
fetches the OFX (ofx.com) public spot-rate-history JSON service per currency pair,
so like Yahoo it serves arbitrary pairs rather than one base currency. Warm a pair
over a window with LoadPairAsync. Options are
OfxRateProviderOptions.
using Bodu.Financial.ExchangeRates;
using var ofx = new OfxRateProvider(new OfxRateProviderOptions());
await ofx.LoadPairAsync("USD", "AUD", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
RateLookupResult aud = ofx.GetRate("USD", "AUD", new DateOnly(2023, 1, 3));
XE.com (any pair)
XeRateProvider
fetches the XE.com charting-rates JSON service per currency pair, so like Yahoo and
OFX it serves arbitrary pairs rather than one base currency. Warm a pair over a
window with LoadPairAsync. The authorization token the endpoint requires is
acquired automatically from the XE website, so no API key or manual setup is needed.
Options are
XeRateProviderOptions.
Warning
This package is Experimental. The authorization token is recovered by scraping an unversioned public XE page, so a change to the site's markup or bundling can silently reduce the provider to empty results - a broken scraper looks the same as "no rate for this pair". Treat it as best-effort: do not rely on it as your sole rate source in production, and pair it with a stable primary feed (for example the ECB, Bank of England, or RBA providers).
using Bodu.Financial.ExchangeRates;
using var xe = new XeRateProvider(new XeRateProviderOptions());
await xe.LoadPairAsync("AUD", "USD", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
RateLookupResult usd = xe.GetRate("AUD", "USD", new DateOnly(2023, 1, 3));
OANDA (any pair)
OandaRateProvider
fetches the OANDA Historical Currency Converter rate-history JSON service per
currency pair, so like Yahoo and OFX it serves arbitrary pairs rather than one base
currency. Warm a pair over a window with LoadPairAsync. Options are
OandaRateProviderOptions.
The anonymous endpoint serves only a rolling recent window - roughly the last 180
days - so a request for an earlier start date returns just what the feed publishes.
The provider advertises this through
HistoryAvailability,
so a caller can resolve the earliest date worth requesting before issuing a lookup.
using Bodu.Financial.ExchangeRates;
using var oanda = new OandaRateProvider(new OandaRateProviderOptions());
var today = DateOnly.FromDateTime(DateTime.UtcNow);
await oanda.LoadPairAsync("AUD", "USD", today.AddDays(-30), today);
RateLookupResult usd = oanda.GetRate("AUD", "USD", today.AddDays(-1));
Fixer (any pair, API key)
FixerRateProvider
fetches the Fixer (fixer.io) time-series and single-date JSON endpoints per currency
pair. It denominates the response against the source currency and requests the
destination currency as the quote symbol. Set the access_key through
FixerRateProviderOptions.ApiKey.
Note
Fixer's free plan is locked to a EUR base and to the latest and single-date endpoints; changing the base currency and the time-series endpoint require a paid plan. A request the plan does not permit surfaces as a fetch failure, so on the free plan request pairs whose source currency is EUR (or rely on the inverse fallback).
using Bodu.Financial.ExchangeRates;
using var fixer = new FixerRateProvider(new FixerRateProviderOptions { ApiKey = "…" });
await fixer.LoadPairAsync("EUR", "USD", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
RateLookupResult usd = fixer.GetRate("EUR", "USD", new DateOnly(2023, 1, 3));
exchangerate.host (any pair, API key)
ExchangeRateHostRateProvider
fetches the exchangerate.host time-series and single-date JSON endpoints per currency
pair. The response keys quotes by the concatenated source+quote code (for example
EURUSD). Set the access_key through
ExchangeRateHostRateProviderOptions.ApiKey;
the free plan is locked to a USD source currency.
using Bodu.Financial.ExchangeRates;
using var host = new ExchangeRateHostRateProvider(new ExchangeRateHostRateProviderOptions { ApiKey = "…" });
await host.LoadPairAsync("USD", "EUR", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
RateLookupResult eur = host.GetRate("USD", "EUR", new DateOnly(2023, 1, 3));
FRED (mapped pairs, API key)
FredRateProvider
serves the St. Louis Fed FRED series/observations endpoint. FRED publishes one
directional series per pair (for example DEXUSEU for EUR/USD), so each pair is
mapped to its series identifier through
FredRateProviderOptions.SeriesMap.
The options ship a built-in map for the major USD pairs and accept more; a pair with
no mapping returns no data. Missing values (FRED's ".") are skipped. Set the
api_key through FredRateProviderOptions.ApiKey.
using Bodu.Financial.ExchangeRates;
using var fred = new FredRateProvider(new FredRateProviderOptions { ApiKey = "…" });
await fred.LoadPairAsync("EUR", "USD", new DateOnly(2023, 1, 1), new DateOnly(2023, 1, 31));
RateLookupResult usd = fred.GetRate("EUR", "USD", new DateOnly(2023, 1, 3));
IMF (USD base, keyless, daily)
ImfRateProvider
serves the IMF Representative Exchange Rates - daily rates reported by member central
banks - downloaded as the IMF's published monthly tab-separated report. Like the
central-bank providers it is a single-base source (base USD): USD→X and X→USD
resolve, cross pairs do not. It is keyless. The report quotes most currencies as units per
USD and a few (for example AUD, GBP, EUR) as USD per unit; the provider normalizes the
quotation direction on ingest, so consumers always see a consistent USD→X rate. Loading is
month-based - one download covers every currency across a month's business days - and closed
months are cached permanently. Options are
ImfRateProviderOptions.
using Bodu.Financial.ExchangeRates;
using var imf = new ImfRateProvider(new ImfRateProviderOptions());
await imf.LoadRangeAsync(new DateOnly(2026, 4, 1), new DateOnly(2026, 4, 30));
RateLookupResult jpy = imf.GetRate("USD", "JPY", new DateOnly(2026, 4, 1));
RateLookupResult usd = imf.GetRate("JPY", "USD", new DateOnly(2026, 4, 1)); // inverted
Registering a provider with dependency injection
Each provider package ships its own DI registration - there is no separate
*.DependencyInjection package. The Add<Source>... extension method registers the
provider on the IFinancialServiceBuilder,
backed by a named HttpClient with the standard Polly resilience handler, and
resolvable as both the dated and timeless surfaces. The Add<Source>... extension
methods live in the Bodu.Financial.ExchangeRates namespace (AddFinancialService
lives in Bodu.Financial), so both using directives bring
the chain into scope:
using Bodu.Financial;
using Bodu.Financial.ExchangeRates;
using Microsoft.Extensions.DependencyInjection;
services.AddFinancialService()
.AddRbaExchangeRates(builder.Configuration) // section Financial:Rba
.AddEcbExchangeRates(builder.Configuration); // section Financial:Ecb
// AddBoeExchangeRates(), AddYahooExchangeRates(), AddOfxExchangeRates(),
// AddXeExchangeRates(), AddOandaExchangeRates(), AddFixerExchangeRates(),
// AddExchangeRateHostExchangeRates(), AddFredExchangeRates(), and
// AddImfExchangeRates() register the others.
Adding caching in front
A provider is a pure fetcher, so wrap it in the caching layer to serve repeated lookups without re-hitting the source. The source must be registered first - the cached registration resolves it, it does not build it:
using Bodu.Financial;
using Bodu.Financial.ExchangeRates;
using Microsoft.Extensions.DependencyInjection;
services.AddFinancialService()
.AddRbaExchangeRates()
.AddCachedRateProvider<RbaRateProvider>("RBA",
configure: o => o.DefaultExpiry = TimeSpan.FromHours(12));
To serve several sources behind one entry point with per-pair routing and a fallback or averaging strategy, group them with the aggregator.
Snapshotting and exporting rates
Every web provider maintains its accumulated observations as an immutable
RateBook plus a ready-to-query
FixedDatedRateProvider,
and both are exposed directly:
GetLoadedBook() and
GetLoadedSnapshot()
return the current instances without copying or locking. The results are pinned
at call time - later fetches swap the provider's internal references and never
mutate an instance already handed out - so a snapshot is deterministic, works
offline, and survives disposing the source provider. Call again after further
loads to observe newly accumulated data.
To pin a window of history from any
IDatedRateProvider -
including a cached or aggregated one - materialize it with
ToFixedProviderAsync:
using Bodu.Financial.Currencies;
using Bodu.Financial.ExchangeRates;
using Bodu.Financial.Extensions;
using var rba = new RbaRateProvider(new RbaRateProviderOptions());
// Fetch and freeze AUD/USD and AUD/EUR for Q1, decoupled from the live provider.
FixedDatedRateProvider q1 = await rba.ToFixedProviderAsync(
new[]
{
new CurrencyPair(CurrencyCode.AUD, CurrencyCode.USD),
new CurrencyPair(CurrencyCode.AUD, CurrencyCode.EUR),
},
new DateOnly(2024, 1, 1),
new DateOnly(2024, 3, 31));
The conversion surface composes in both directions. A rate sequence - for
example the rows a range lookup returned - materializes into a book with
ToBook(),
which keeps one series per (pair, provider) and stores inverse-resolved rows
under their natively quoted direction; a book wraps into a provider with
ToFixedProvider()
(optionally with a provider-priority list); and
RateBook.ToBuilder()
round-trips a book into a mutable
RateTableBuilder for
editing - book.ToBuilder() … edit … ToBook().ToFixedProvider(). Each series'
fetch instant (FetchedAtUtc) is preserved through every step, so provenance
survives a web → fixed round trip losslessly.
Failure modes and exceptions
Every provider fails the same way, because the failures are raised by the shared bases or by the shared exception types the sources are required to use. Each row names the type, the condition, and where in the call chain it surfaces:
| Exception | Raised when | Surfaces from |
|---|---|---|
KeyNotFoundException |
A pair the provider covers has no observation on the requested date under the lookup options - the ordinary miss. TryGetRate returns false for the same case. |
GetRate / GetRateAsync, after any fetch the call was allowed to make |
RateSeriesNotFoundException (a KeyNotFoundException) |
A single-base feed is asked for a pair it structurally cannot carry - USD/JPY on the ECB, a cross pair on RBA, BoE, or IMF. Thrown by ValidateRangeRequest before any download. Catch it ahead of the base type to tell "never" from "not today". |
GetRates / GetRatesAsync / LoadPairAsync / LoadRangeAsync |
ExchangeRateFormatException (a FormatException) |
A downloaded payload is malformed or lacks the expected rows (a maintenance page, an API error envelope, a changed schema). Never retried by the resilience pipeline. | The call that triggered the fetch: a warm-up, an asynchronous lookup, or a synchronous lookup with AllowSynchronousNetworkAccess on |
HttpRequestException |
DNS, connection, TLS, or a non-success status. On a hand-built provider it is immediate; under DI it appears only after the standard resilience handler has exhausted its retries and timeouts. | The call that triggered the fetch |
TaskCanceledException |
The per-request timeout of a provider-owned HttpClient (HttpTimeout), or the caller's token. |
The call that triggered the fetch |
InvalidOperationException |
AllowSynchronousNetworkAccess is true, a synchronous lookup misses, and the calling thread carries a SynchronizationContext - blocking there could deadlock, so the provider refuses. With the option at its default false, a synchronous miss is simply a miss (row 1) and never reaches the network. |
Synchronous GetRate / TryGetRate / GetRates |
ArgumentException / ArgumentNullException |
A malformed or null ISO code, an inverted range (endDate < startDate), or options that fail Validate in a constructor. |
Argument validation, before any work |
ObjectDisposedException |
Any member after Dispose(). Snapshots already handed out stay valid. |
Every public member |
Each case, exercised against the ECB provider over a
StubHttpMessageHandler (the two-day eurofxref
document from Testing your own provider
is the feed constant here), with the payload cache off so nothing touches the disk:
using System.Net;
using System.Text;
using Bodu.Financial.ExchangeRates;
using Bodu.Financial.ExchangeRates.Testing;
static EcbRateProvider CreateEcb(byte[] payload, HttpStatusCode status = HttpStatusCode.OK, bool allowSync = false) =>
new(new HttpClient(new StubHttpMessageHandler(payload, status)),
new EcbRateProviderOptions { EnableDiskCache = false, AllowSynchronousNetworkAccess = allowSync });
byte[] feed = Encoding.UTF8.GetBytes(EcbFeed);
// 1. A pair the single-base feed structurally cannot serve: rejected before any download.
using (EcbRateProvider ecb = CreateEcb(feed))
{
try { await ecb.GetRatesAsync("USD", "JPY", new DateOnly(2023, 1, 3), new DateOnly(2023, 1, 4)); }
catch (RateSeriesNotFoundException ex) { Console.WriteLine($"cross pair: {ex.Message}"); }
}
// 2. A covered pair with no observation on the date: an ordinary miss.
using (EcbRateProvider ecb = CreateEcb(feed))
{
await ecb.LoadRangeAsync(new DateOnly(2023, 1, 3), new DateOnly(2023, 1, 4));
Console.WriteLine(ecb.TryGetRate("EUR", "USD", new DateOnly(2023, 1, 1), RateLookupOptions.Exact, out _)); // False
try { ecb.GetRate("EUR", "USD", new DateOnly(2023, 1, 1)); }
catch (KeyNotFoundException ex) when (ex is not RateSeriesNotFoundException) { Console.WriteLine($"miss: {ex.Message}"); }
}
// 3. A payload the parser cannot read.
using (EcbRateProvider ecb = CreateEcb(Encoding.UTF8.GetBytes("<html>maintenance page</html>")))
{
try { await ecb.LoadRangeAsync(new DateOnly(2023, 1, 3), new DateOnly(2023, 1, 4)); }
catch (ExchangeRateFormatException ex) { Console.WriteLine($"format: {ex.Message}"); }
}
// 4. Transport failure: on a hand-built client there is no retry; under DI the resilience
// pipeline retries first and this surfaces only when it gives up.
using (EcbRateProvider ecb = CreateEcb(Array.Empty<byte>(), HttpStatusCode.BadGateway))
{
try { await ecb.LoadRangeAsync(new DateOnly(2023, 1, 3), new DateOnly(2023, 1, 4)); }
catch (HttpRequestException ex) { Console.WriteLine($"transport: {ex.StatusCode}"); }
}
// 5. A synchronous miss with AllowSynchronousNetworkAccess left at false never touches the network:
// it is reported as a miss, exactly like case 2.
using (EcbRateProvider ecb = CreateEcb(feed))
{
Console.WriteLine(ecb.TryGetRate("EUR", "USD", new DateOnly(2023, 1, 3), null, out _)); // False - nothing loaded, no fetch
}
// 6. With AllowSynchronousNetworkAccess enabled, a synchronous miss blocks to fetch - unless the calling
// thread carries a SynchronizationContext, where blocking could deadlock: InvalidOperationException instead.
using (EcbRateProvider ecb = CreateEcb(feed, allowSync: true))
{
SynchronizationContext? previous = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
try
{
ecb.GetRates("EUR", "USD", new DateOnly(2023, 1, 3), new DateOnly(2023, 1, 4));
}
catch (InvalidOperationException ex) { Console.WriteLine($"captured context: {ex.Message}"); }
finally { SynchronizationContext.SetSynchronizationContext(previous); }
}
Fetch failures (rows 3-5) are logged at DownloadFailedLogLevel (Warning by default) and
rethrown; the pair base logs anything else under a distinct error event so a bug is not
mislabelled as a feed problem. Cancellation is never logged. Under the
caching decorator, a miss for a date the provider has declared
unavailable is answered without the provider being called at all.
Range reads and RateRangeResult
GetRates / GetRatesAsync return every observation whose date falls inside the inclusive
window as a RateRangeResult. It implements
IReadOnlyList<ExchangeRate>, so it enumerates, indexes, and composes with LINQ like a plain
sequence - and it carries the request alongside the data, so a caller can see how much of the
window actually had observations without re-deriving it:
| Member | Meaning |
|---|---|
FromIsoCode / ToIsoCode |
The requested direction. |
RequestedStartDate / RequestedEndDate |
The inclusive window you asked for. |
Rates |
The observations, ordered by date; also exposed through Count, this[int], and enumeration. |
IsEmpty |
true when no observation fell inside the window - an empty window is a result, not an exception. |
FirstObservedDate / LastObservedDate |
The observed span, or null when empty. Compare with the requested window to measure the gap at either end. |
No date-resolution policy applies to a range - you get the rows that exist - and each row's
Provider, Date, and IsInverted travel on the ExchangeRate itself, so nothing is
repeated per row:
using Bodu.Financial.Currencies;
using Bodu.Financial.ExchangeRates;
IDatedRateProvider rates = new FixedDatedRateProvider(new[]
{
new ExchangeRate(CurrencyCode.AUD, CurrencyCode.USD, new DateOnly(2024, 1, 3), 0.6606m, "RBA"),
new ExchangeRate(CurrencyCode.AUD, CurrencyCode.USD, new DateOnly(2024, 1, 4), 0.6629m, "RBA"),
new ExchangeRate(CurrencyCode.AUD, CurrencyCode.USD, new DateOnly(2024, 1, 5), 0.6648m, "RBA"),
});
RateRangeResult january = rates.GetRates("AUD", "USD", new DateOnly(2024, 1, 1), new DateOnly(2024, 1, 7));
Console.WriteLine($"{january.FromIsoCode}/{january.ToIsoCode}: {january.Count} observations"); // AUD/USD: 3 observations
Console.WriteLine($"{january.RequestedStartDate:yyyy-MM-dd}..{january.RequestedEndDate:yyyy-MM-dd}"); // 2024-01-01..2024-01-07
Console.WriteLine($"{january.FirstObservedDate:yyyy-MM-dd}..{january.LastObservedDate:yyyy-MM-dd}"); // 2024-01-03..2024-01-05
Console.WriteLine(january.IsEmpty); // False
Console.WriteLine(january[0].Rate); // 0.6606 - IReadOnlyList<ExchangeRate>
decimal average = january.Average(r => r.Rate);
int missingDays = (january.RequestedEndDate.DayNumber - january.RequestedStartDate.DayNumber + 1) - january.Count;
Console.WriteLine($"average {average:0.0000}, {missingDays} days without an observation");
// The reverse direction is answered by reciprocating each row; IsInverted records it.
RateRangeResult usdAud = rates.GetRates("USD", "AUD", new DateOnly(2024, 1, 1), new DateOnly(2024, 1, 7));
Console.WriteLine($"{usdAud[0].Rate:0.0000} inverted={usdAud[0].IsInverted}"); // 1.5138 inverted=True
RateRangeResult empty = rates.GetRates("AUD", "USD", new DateOnly(2023, 6, 1), new DateOnly(2023, 6, 30));
Console.WriteLine($"{empty.IsEmpty} {empty.FirstObservedDate is null}"); // True True - an empty window does not throw
On a web provider the asynchronous form fetches whatever unit covers the window first (the
pair, the era, the feed); the synchronous form serves the current snapshot and blocks to fetch
only under AllowSynchronousNetworkAccess. A range that starts before the provider's
advertised history is served from what exists - see
Respecting advertised history for
how the caching layer clamps such requests.
Discovering series
A provider knows nothing about a pair until it has fetched it. Afterwards, two views report what
it holds. GetAvailablePairs() - declared on each provider with its own series type - returns
one metadata object per fetched pair, carrying what the feed reported about it;
GetLoadedPairs(), from the provider-agnostic
IPairRateLoader, projects the same set to plain
CurrencyPairs so cache-warming code can treat every provider alike. Both return a snapshot
array; a cold provider reports nothing.
| Provider | Series type | Members beyond Pair |
|---|---|---|
| RBA | RbaSeriesInfo |
QuoteIsoCode, SeriesId, Description, Units - the workbook column. |
| ECB | EcbSeriesInfo |
QuoteIsoCode. |
| Bank of England | BoeSeriesInfo |
QuoteIsoCode, SeriesCode, Description - the IADB series. |
| IMF | ImfSeriesInfo |
QuoteIsoCode. |
| Yahoo Finance | YahooSeriesInfo |
Symbol (the ticker, AUDUSD=X), QuoteIsoCode. |
| OFX | OfxSeriesInfo |
QuoteIsoCode. |
| XE.com | XeSeriesInfo |
QuoteIsoCode. |
| OANDA | OandaSeriesInfo |
QuoteIsoCode, Price (bid / mid / ask). |
| Fixer | FixerSeriesInfo |
BaseIsoCode, QuoteIsoCode. |
| exchangerate.host | ExchangeRateHostSeriesInfo |
SourceIsoCode, QuoteIsoCode. |
| FRED | FredSeriesInfo |
SeriesId (the FRED identifier, DEXUSEU). |
using Bodu.Financial.ExchangeRates;
Console.WriteLine(ecb.GetAvailablePairs().Count); // 0 - nothing fetched yet
await ecb.LoadRangeAsync(new DateOnly(2023, 1, 3), new DateOnly(2023, 1, 4));
foreach (EcbSeriesInfo series in ecb.GetAvailablePairs())
Console.WriteLine($"{series.Pair.From}/{series.Pair.To} quote={series.QuoteIsoCode}"); // EUR/USD quote=USD, EUR/JPY quote=JPY
// The provider-agnostic view: the same pairs, without the feed-specific metadata.
IPairRateLoader loader = ecb;
foreach (CurrencyPair pair in loader.GetLoadedPairs())
Console.WriteLine($"{pair.From}/{pair.To}");
// The advertised depth - what to ask for, not a per-date guarantee.
RateHistoryAvailability history = ecb.HistoryAvailability;
DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow);
Console.WriteLine($"{history.Kind}: earliest {history.GetEarliestAvailable(today)}");
Discovery is post hoc by design: none of the feeds publishes a cheap catalogue endpoint, so
the providers report what they have loaded rather than pretend to know what the source could
serve. To find out whether a pair would resolve, warm it (LoadPairAsync, or
LoadRangeAsync on a bulk feed) and inspect the result; a single-base feed rejects a cross
pair up front with RateSeriesNotFoundException, as the table above describes.
Choosing a provider
| Need | Reach for |
|---|---|
| Official AUD rates with deep history | RBA |
| Official EUR reference rates | ECB |
| Official GBP spot rates | BoE |
| An arbitrary pair not quoted by a central bank | Yahoo, OFX, XE, OANDA, Fixer, or exchangerate.host |
| Recent rates for an arbitrary pair (rolling ~180-day window) | OANDA |
| A commercial API you already hold a key for | Fixer or exchangerate.host |
| Official US-published FX series (per mapped pair) | FRED |
| Official daily USD representative rates (keyless) | IMF |
| One pair from several sources, with fallback or an average | the aggregator over any mix |
See also
- Working with exchange rates - the provider contracts, lookup options, provenance, and series these providers serve.
- Caching and aggregating exchange rates - adding a read-through cache and grouping providers.
- Exchange-rate types catalogue - every FX type mapped to a scenario.
RbaRateProvider,EcbRateProvider,BoeRateProvider,YahooRateProvider,OfxRateProvider,XeRateProvider,OandaRateProvider,FixerRateProvider,ExchangeRateHostRateProvider,FredRateProvider,ImfRateProvider