Table of Contents

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