Current section
Files
Jump to
Current section
Files
lib/forex.ex
defmodule Forex do
@moduledoc """
`Forex` is a simple Elixir library that serves as a wrapper to the
foreign exchange reference rates provided by the European Central Bank.
> ### From the [European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) {: .info}
>
> The reference rates are usually updated at around **16:00 CET** every working day, except on
> [TARGET closing days](https://www.ecb.europa.eu/ecb/contacts/working-hours/html/index.en.html).
>
> They are based on the daily concertation procedure between central banks across Europe,
> which normally takes place around 14:10 CET. The reference rates are published for
> information purposes only. Using the rates for transaction
> purposes is _strongly discouraged_.
## Motivation
Even though there are other libraries in the Elixir ecosystem that provide
similar functionality (example: `ex_money`), `Forex` was created with the intent
of providing access to currency exchange rates for projects that want to self-host
the data and not rely on third-party paid services in a simple and straightforward
manner. No API keys, no authentication, no rate limits, just a simple Elixir library
that fetches the data from the European Central Bank and caches it for later use.
## Options
* `:base` - The base currency to convert the rates to. The default currency base is `:eur`.
* `:format` - The format of the rate value. Supported values are `:decimal` and `:string`.
The default is `:decimal`.
* `:round` - The number of decimal places to round the rate value to. The default is `5`.
* `:symbols` - A list of currency codes (atoms or strings) to filter the rates by.
The default is `nil`, which means all available currencies will be returned.
* `:keys` - The format of the keys in the rate map. Supported values are `:strings` and `:atoms`.
The default is `:atoms`.
* `:use_cache` - A boolean value to enable or disable the cache. To enable the cache check the
usage section docs for detailed instructions. The default is `true`.
* `:feed_fn` - An `mfa` tuple that can be used to fetch the exchange rates from a custom feed.
This option is mostly used for testing purposes. The default is `nil`, which means the
default feed will be used.
## Usage
By default the `base` currency is the Euro (EUR), the same as the European Central Bank,
but you change the base currency by passing the `base` option to the relevant functions.
To fetch the latest exchange rates, you can use the `current_rates/1` function:
```elixir
iex> Forex.current_rates()
{:ok,
%{
base: :eur,
date: ~D[2025-03-12],
rates: %{
usd: Decimal.new("1.1234"),
jpy: Decimal.new("120.1234"),
...
zar: Decimal.new("24.1442")
}}
}
```
To fetch the exchange rates for the last ninety days, you can use the `last_ninety_days_rates/1` function:
```elixir
iex> Forex.last_ninety_days_rates()
{:ok,
[
%{
date: ~D[2025-03-12],
base: :eur,
rates: %{
usd: Decimal.new("1.1234"),
jpy: Decimal.new("120.1234"),
...
zar: Decimal.new("24.1442")
}
},
...
]}
```
To fetch the historic exchange rates (for any working day since 4 January 1999),
you can use the `historic_rates/1` function:
```elixir
iex> Forex.historic_rates()
{:ok,
[
%{
date: ~D[2025-03-12],
base: :eur,
rates: %{
usd: Decimal.new("1.1234"),
jpy: Decimal.new("120.1234"),
...
zar: Decimal.new("24.1442")
}
},
...
]}
```
To fetch the exchange rates for a specific date, you can use the `get_historic_rate/2` function:
```elixir
iex> Forex.get_historic_rate(~D[2025-02-25])
{:ok,
[
%{
date: ~D[2025-03-12],
base: :eur,
rates: %{
usd: Decimal.new("1.1234"),
jpy: Decimal.new("120.1234"),
...
zar: Decimal.new("24.1442")
}
},
...
]}
```
To fetch the exchange rates between two dates, you can use the `get_historic_rates_between/3` function:
```elixir
iex> Forex.get_historic_rates_between(~D[2025-02-25], ~D[2025-02-28])
{:ok,
[
%{
date: ~D[2025-03-12],
base: :eur,
rates: %{
usd: Decimal.new("1.1234"),
jpy: Decimal.new("120.1234"),
...
zar: Decimal.new("24.1442")
}
},
...
]}
```
To convert an amount from one currency to another, you can use the `exchange/4` function:
```elixir
iex> Forex.exchange(100, "USD", "EUR")
{:ok, Decimal.new("91.86100")}
iex> Forex.exchange(420, :eur, :gbp)
{:ok, Decimal.new("353.12760")}
```
To list all available currencies from the European Central Bank,
you can use the `available_currencies/1` function:
```elixir
iex> Forex.available_currencies()
[:try, :eur, :aud, :bgn, :brl, ...]
```
## Mix Tasks
The `Forex` library also provides mix tasks to export the exchange rates
to a JSON file. You can use the following tasks:
* `mix forex.export.current` - Export the current rates to a JSON file.
* `mix forex.export.historic` - Export the historic rates to a JSON file.
* `mix forex.export.ninety` - Export the last ninety days rates to a JSON file.
The tasks accept some options to customize the output:
* `--base` - The base currency to use. Defaults to `EUR`.
* `--symbols` - The currencies to fetch. Defaults to all currencies.
* `--output` - The output directory. Defaults to `priv/data/forex`.
* `--help` - Show this help message.
For example the task `mix forex.export.current --base USD --symbols EUR,USD,GBP`
will export the current exchange rates for EUR, USD and GBP to a `current_rates.json`
JSON file in the `priv/data/forex` directory with the base currency set to USD.
"""
@typedoc """
The currency code is a three-letter code that represents a currency,
in accordance with the ISO 4217 standard.
It can be either a string or an atom.
"""
@type currency_code :: String.t() | atom()
@typedoc """
A currency rate, represented as a map with the currency code as the key
and the rate as the value. The rate can be either a Decimal or a string.
"""
@type rate :: %{currency_code() => Decimal.t() | String.t()}
# A date that can be either a string in the ISO 8601 format, a Date struct
# or a tuple with the date components in the format `{year, month, day}`.
@typep maybe_date :: String.t() | tuple() | Date.t()
import Forex.Support
alias Forex.Currency
alias Forex.Fetcher
## Options
defp options_schema do
NimbleOptions.new!(
base: [type: {:in, currency_schema_options()}, default: :eur],
format: [type: {:in, ~w(decimal string)a}, default: :decimal],
round: [type: {:or, [:integer, nil]}, default: 5],
symbols: [
type: {:or, [{:list, {:in, currency_schema_options()}}, nil]},
default: nil
],
keys: [type: {:in, ~w(strings atoms)a}, default: :atoms],
use_cache: [type: :boolean, default: true],
feed_fn: [type: :mfa, default: nil]
)
end
@doc """
Validate and return the options for the Forex module functions,
using default values if the options are not provided.
## Options
* `:base` - The base currency to convert rates to (default: `:eur`)
* `:format` - Format of rate values (`:decimal` or `:string`, default: `:decimal`)
* `:round` - Decimal places for rounding (default: `5`)
* `:symbols` - Currency codes to include (default: `nil` for all currencies)
* `:keys` - Map key format (`:atoms` or `:strings`, default: `:atoms`)
* `:use_cache` - Whether to use cached rates (default: `true`)
* `:feed_fn` - Optional custom feed function as `{module, function, args}` (default: `nil`)
"""
def options(opts \\ []) do
NimbleOptions.validate!(opts, options_schema())
|> Enum.into(%{})
end
@doc """
Returns the configured JSON encoding library for Forex.
The default is the `Jason` library.
The JSON library must implement the `encode/1` function.
The JSON library is only required when using the mix tasks
to export the exchange rates to a JSON file, otherwise this
setting can be ignored.
To customize the JSON library, including the following
in your `config/config.exs`:
config :forex, :json_library, AlternativeJsonLibrary
The library must implement the `encode_to_iodata!/2` function.
"""
def json_library do
Application.get_env(:forex, :json_library, Jason)
end
# The allowed currency codes for the schema options
defp currency_schema_options do
string_keys_upper = available_currencies(:strings)
string_keys_lower = Enum.map(string_keys_upper, &String.downcase/1)
atom_keys_lower = available_currencies(:atoms)
atom_keys_upper =
Enum.map(atom_keys_lower, fn k ->
Atom.to_string(k)
|> String.upcase()
|> String.to_atom()
end)
string_keys_lower ++ string_keys_upper ++ atom_keys_lower ++ atom_keys_upper
end
## Currencies
@doc """
Return a list of all available currencies ISO 4217 codes.
"""
def available_currencies(keys \\ :atoms)
def available_currencies(:strings) do
Currency.available(:strings)
|> Map.keys()
end
def available_currencies(:atoms) do
Currency.available(:atoms)
|> Map.keys()
end
@doc """
Return a list of all available currencies.
"""
def list_currencies(keys \\ :atoms)
def list_currencies(:atoms), do: Currency.available(:atoms)
def list_currencies(:strings), do: Currency.available(:strings)
@doc """
Return a list of all available currencies in the format
`%{currency_code() => currency_name()}`.
Useful for input forms, selects, etc.
"""
def currency_options(keys \\ :atoms)
def currency_options(:atoms) do
list_currencies(:atoms)
|> Enum.map(fn {code, currency} ->
{currency.name, code}
end)
end
def currency_options(:strings) do
list_currencies(:strings)
|> Enum.map(fn {code, currency} ->
{currency.name, code}
end)
end
@doc """
Get the currency information for the given ISO code.
"""
def get_currency(currency_code),
do: Currency.get(currency_code)
@doc """
Get the currency information for the given ISO code.
Like `get_currency/1`, but raises an error if the currency is not found.
"""
def get_currency!(currency_code),
do: Currency.get!(currency_code)
@doc """
Exchange a given amount from one currency to another.
It will use the cached exchange rates from the European Central
Bank (ECB) or fetch the latest rates if the cache is disabled.
## Options
* `:format` - The format of the rate value. Supported values are `:decimal` and `:string`. The default is `:decimal`.
* `:round` - The number of decimal places to round the rate value to. The default is `4`.
## Examples
```elixir
iex> Forex.exchange(100, "USD", "EUR")
{:ok, Decimal.new("91.86100")}
iex> Forex.exchange(420, :eur, :gbp, format: :string)
{:ok, "353.12760"}
iex> Forex.exchange(123, :gbp, :usd, format: :string, round: 1)
{:ok, "159.3"}
```
"""
@spec exchange(number() | Decimal.t(), currency_code(), currency_code(), keyword()) ::
{:ok, Decimal.t()} | {:error, term}
def exchange(amount, from, to, opts \\ []),
do: Currency.exchange(amount, from, to, opts)
@doc """
Same as `exchange/3`, but raises an error if the request fails.
"""
def exchange!(amount, from, to, opts \\ []),
do: Currency.exchange!(amount, from, to, opts)
## Exchange Rates
@doc """
Fetch the latest exchange rates from the European Central Bank (ECB).
## Return Value
Returns `{:ok, %{base: atom(), date: Date.t(), rates: map()}}` on success where:
- `base` is the base currency code
- `date` is the reference date
- `rates` is a map of currency codes to rate values
## Arguments
* `opts` - Options:
* `:format` - Format of rates (`:decimal` or `:string`, default: `:decimal`)
* `:base` - Base currency (default: `EUR`)
* `:symbols` - List of currency codes to include
* `:keys` - Key format in rates map (`:atoms` or `:strings`)
* `:use_cache` - Whether to use cached rates (default: `true`)
## Examples
```elixir
{:ok, %{base: :eur, date: ~D[2025-03-12], rates: %{usd: Decimal.new("1.1234"), jpy: Decimal.new("120.1234"), ...}}}
```
"""
@spec current_rates(keyword) :: {:ok, rate()} | {:error, term}
def current_rates(opts \\ []) when is_list(opts) do
opts = options(opts)
case Fetcher.get(:current_rates, use_cache: opts.use_cache, feed_fn: opts.feed_fn) do
{:ok, entries} ->
result =
Enum.map(entries, fn %{time: datetime, rates: rates} ->
%{
date: map_date(datetime),
base: Map.get(opts, :base),
rates: map_rates(rates, options(opts))
}
end)
|> List.first()
{:ok, result}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Same as `current_rates/1`, but raises an error if the request fails.
"""
@spec current_rates!(keyword) :: rate()
def current_rates!(opts \\ []) when is_list(opts) do
case current_rates(opts) do
{:ok, rates} -> rates
{:error, reason} -> raise reason
end
end
@doc """
Fetch the exchange rates for the last ninety days
from the European Central Bank (ECB).
Note that rates are only available on working days.
## Arguments
Same options as `current_rates/1`.
"""
@spec last_ninety_days_rates(keyword) :: {:ok, [rate()]} | {:error, term}
def last_ninety_days_rates(opts \\ []) when is_list(opts) do
opts = options(opts)
case Fetcher.get(:last_ninety_days_rates, use_cache: opts.use_cache, feed_fn: opts.feed_fn) do
{:ok, entries} ->
results =
entries
|> Stream.map(fn %{time: datetime, rates: rates} ->
%{
date: map_date(datetime),
base: Map.get(opts, :base),
rates: map_rates(rates, options(opts))
}
end)
|> Enum.filter(fn %{date: date} -> date != nil end)
{:ok, results}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Same as `last_ninety_days_rates/1`, but raises an error if the request fails.
"""
@spec last_ninety_days_rates!(keyword) :: [rate()]
def last_ninety_days_rates!(opts \\ []) when is_list(opts) do
case last_ninety_days_rates(opts) do
{:ok, entries} -> entries
{:error, reason} -> raise reason
end
end
@doc """
Fetch the historic exchange rates feed from the European
Central Bank (ECB) for any working day since 4 January 1999.
By default, the historic rates are not automatically fetched when using
the Fetcher (scheduler) module, since the whole file is returned this avoids excessive memory
usage when caching the results if not needed. To fetch and cache the historic rates,
you need to manually call this function.
Same options as `current_rates/1`.
"""
@spec historic_rates(keyword) :: {:ok, [rate()]} | {:error, term}
def historic_rates(opts \\ []) when is_list(opts) do
opts = options(opts)
case Fetcher.get(:historic_rates, use_cache: opts.use_cache, feed_fn: opts.feed_fn) do
{:ok, entries} ->
results =
entries
|> Stream.map(fn %{time: datetime, rates: rates} ->
%{
date: map_date(datetime),
base: Map.get(opts, :base),
rates: map_rates(rates, opts)
}
end)
|> Enum.filter(fn %{date: date} -> date != nil end)
{:ok, results}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Same as `historic_rates/1`, but raises an error if the request fails.
"""
@spec historic_rates!(keyword) :: [rate()]
def historic_rates!(opts \\ []) when is_list(opts) do
case historic_rates(opts) do
{:ok, entries} -> entries
{:error, reason} -> raise reason
end
end
@doc """
Get a specific date from the historic exchange rates feed.
It returns either an `{:ok, rate()}` if the rate was successfully
retrieved or an `{:error, reasons}` if the rate was not found.
## Arguments
* `date` - The date to get the rate for. `date` is either a `Date.new()` struct
or a string in the ISO 8601 format.
* `opts` - Same options as `current_rates/1`.
"""
@spec get_historic_rate(maybe_date(), keyword) ::
{:ok, [rate()]} | {:error, term}
def get_historic_rate(date, opts \\ [])
def get_historic_rate(date, opts) when is_binary(date) or is_tuple(date) do
case parse_date(date) do
{:ok, date} -> get_historic_rate(date, opts)
{:error, :invalid_date} -> raise Forex.DateError, "Invalid date format"
end
end
def get_historic_rate(%Date{calendar: Calendar.ISO} = date, opts) when is_list(opts) do
case historic_rates(opts) do
{:ok, entries} ->
case find_historic_rate_date(entries, date) do
nil -> {:error, {Forex.DateError, "Rate not found for date: #{Date.to_iso8601(date)}"}}
entry -> {:ok, entry.rates}
end
{:error, reason} ->
{:error, reason}
end
end
defp find_historic_rate_date(entries, date) do
Enum.find(entries, fn
%{date: %Date{} = d} -> Date.compare(date, d) == :eq
_ -> false
end)
end
@doc """
Same as `get_historic_rate/2`, but raises an error if the request fails.
"""
@spec get_historic_rate!(maybe_date(), keyword) :: [rate()]
def get_historic_rate!(date, opts \\ [])
def get_historic_rate!(date, opts) when is_binary(date) or is_tuple(date) do
case parse_date(date) do
{:ok, date} -> get_historic_rate!(date, opts)
{:error, :invalid_date} -> raise Forex.DateError, "Invalid date format"
end
end
def get_historic_rate!(%Date{calendar: Calendar.ISO} = date, opts) when is_list(opts) do
case get_historic_rate(date, opts) do
{:ok, rates} -> rates
{:error, reason} -> raise Forex.FeedError, reason
end
end
@doc """
Get exchange rates between two dates from the historic exchange rates feed.
Returns a list of exchange rates for each working day between the start and end date.
## Arguments
* `start_date` - Start date (Date, ISO 8601 string, or {year, month, day} tuple)
* `end_date` - End date (Date, ISO 8601 string, or {year, month, day} tuple)
* `opts` - Same options as `current_rates/1`
## Return Value
Returns `{:ok, [%{date: Date.t(), base: atom(), rates: map()}]}` where each list item
represents rates for one day.
## Examples
```elixir
iex> Forex.get_historic_rates_between("2023-01-01", "2023-01-05")
{:ok, [
%{date: ~D[2023-01-02], base: :eur, rates: %{usd: Decimal.new("1.0678", ...}},
%{date: ~D[2023-01-03], base: :eur, rates: %{usd: Decimal.new("1.0545", ...}},
%{date: ~D[2023-01-04], base: :eur, rates: %{usd: Decimal.new("1.0599", ...}},
%{date: ~D[2023-01-05], base: :eur, rates: %{usd: Decimal.new("1.0556", ...}}
]}
```
"""
@spec get_historic_rates_between(maybe_date(), maybe_date(), keyword) ::
{:ok, [rate()]} | {:error, term}
def get_historic_rates_between(start_date, end_date, opts \\ [])
def get_historic_rates_between(start_date, end_date, opts)
when is_binary(start_date) and is_binary(end_date) do
with {:ok, start_date} <- parse_date(start_date),
{:ok, end_date} <- parse_date(end_date) do
get_historic_rates_between(start_date, end_date, opts)
else
{:error, _} -> {:error}
end
end
def get_historic_rates_between(
%Date{calendar: Calendar.ISO} = start_date,
%Date{calendar: Calendar.ISO} = end_date,
opts
)
when is_list(opts) do
case historic_rates(opts) do
{:ok, entries} ->
entries_range =
Enum.filter(entries, fn
%{date: %Date{} = date} ->
Date.compare(date, start_date) != :lt and Date.compare(date, end_date) != :gt
_ ->
false
end)
{:ok, entries_range}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Same as `get_historic_rates_between/3`, but raises an error if the request fails.
"""
@spec get_historic_rates_between!(maybe_date(), maybe_date(), keyword) :: [rate()]
def get_historic_rates_between!(start_date, end_date, opts \\ [])
def get_historic_rates_between!(start_date, end_date, opts)
when is_binary(start_date) and is_binary(end_date) do
with {:ok, start_date} <- parse_date(start_date),
{:ok, end_date} <- parse_date(end_date) do
get_historic_rates_between!(start_date, end_date, opts)
else
{:error, _} -> {:error, {Forex.DateError, "Invalid date format"}}
end
end
def get_historic_rates_between!(
%Date{calendar: Calendar.ISO} = start_date,
%Date{calendar: Calendar.ISO} = end_date,
opts
)
when is_list(opts) do
case get_historic_rates_between(start_date, end_date, opts) do
{:ok, rates} -> rates
{:error, reason} -> raise Forex.FeedError, reason
end
end
@doc """
Last updated date of the exchange rates feed.
Lists the last date the exchange rates were updated from the cache.
Example:
iex> Forex.last_updated()
[
current_rates: ~U[2024-11-23 18:19:38.974337Z],
historic_rates: ~U[2024-11-23 18:27:07.391035Z],
last_ninety_days_rates: ~U[2024-11-23 18:19:39.111818Z],
]
"""
@spec last_updated() :: Keyword.t() | nil
def last_updated do
if Forex.Cache.initialized?() do
Forex.Cache.last_updated()
else
nil
end
end
## Private Functions
# Default base currency rate
def base_currency_rate, do: %{currency: "EUR", rate: "1.00000"}
# Map the rates response to the format %{currency_code() => Decimal.t()}
# If not EUR based currency we rebase the rates to the new base currency
defp map_rates({:error, reason}, _), do: {:error, reason}
defp map_rates({:ok, rates}, opts), do: map_rates(rates, opts)
defp map_rates(rates, opts) when is_list(rates) do
[base_currency_rate() | rates]
|> maybe_filter_currencies(opts.symbols)
|> Currency.maybe_rebase(opts.base)
|> case do
{:ok, rebased_rates} ->
rebased_rates
|> Stream.map(fn %{currency: currency, rate: value} ->
{maybe_atomize_code(currency, opts.keys), rate_value(value, opts)}
end)
|> Enum.into(%{})
error ->
error
end
end
defp map_rates(_, _), do: []
# Format the rate value based on the options
defp rate_value(value, opts) do
value
|> format_value(opts.format)
|> round_value(opts.round)
end
# Filter the rates based on the symbols option
defp maybe_filter_currencies(rates, nil), do: rates
defp maybe_filter_currencies(rates, []), do: rates
defp maybe_filter_currencies(rates, symbols) when is_list(symbols) do
symbols = Enum.map(symbols, &stringify_code/1)
Enum.filter(rates, fn %{currency: currency} ->
Enum.member?(symbols, currency)
end)
end
defp maybe_filter_currencies(rates, _), do: rates
defp maybe_atomize_code(code, :atoms), do: atomize_code(code)
defp maybe_atomize_code(code, :strings), do: stringify_code(code)
end