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