Current section
Files
Jump to
Current section
Files
lib/yf_quote.ex
defmodule YfQuote do
@moduledoc """
A module for fetching prices from Yahoo Finance with currency conversion
"""
defmodule Quote do
@moduledoc """
Structure representing a price quote
"""
defstruct [:price, :currency, :symbol, :datetime, :market_timezone]
@type t :: %__MODULE__{
price: float() | nil,
currency: String.t(),
symbol: String.t(),
datetime: DateTime.t(),
market_timezone: String.t() | nil
}
end
defimpl Jason.Encoder, for: YfQuote.Quote do
def encode(quote, opts) do
Jason.Encode.map(
%{
price: quote.price,
currency: quote.currency,
symbol: quote.symbol,
datetime: DateTime.to_iso8601(quote.datetime),
market_timezone: quote.market_timezone
},
opts
)
end
end
# Yahoo Finance API configuration
@yahoo_finance_base_url "https://query1.finance.yahoo.com/v8/finance/chart"
@rate_limit_delay_ms 200
@forex_retry_count 5
@forex_retry_delay_base_ms 1000
# Valid currency codes (ISO 4217)
@valid_currencies_list [
"USD",
"EUR",
"GBP",
"JPY",
"CHF",
"CAD",
"AUD",
"NZD",
"SEK",
"NOK",
"DKK",
"PLN",
"CZK",
"HUF",
"TRY",
"ZAR",
"MXN",
"BRL",
"CNY",
"HKD",
"SGD",
"INR",
"KRW",
"THB",
"PHP",
"IDR",
"MYR",
"VND",
"TWD",
"RUB",
"ILS",
"AED",
"SAR",
"QAR",
"KWD",
"BHD",
"OMR",
"JOD",
"LBP",
"EGP",
"MAD",
"TND",
"DZD",
"LYD",
"GHS",
"NGN",
"KES",
"UGX",
"TZS",
"RWF",
"ETB",
"XOF",
"XAF",
"CLP",
"PEN",
"COP",
"VEF",
"UYU",
"ARS",
"BOB",
"PYG",
"CRC",
"GTQ",
"HNL",
"NIO",
"PAB"
]
# Converts forex format AAA/BBB to Yahoo format AAABBB=X if valid currencies
defp convert_forex_format(ticker) do
case String.split(ticker, "/") do
[base, quote] ->
if String.length(base) == 3 and String.length(quote) == 3 do
base_upper = String.upcase(base)
quote_upper = String.upcase(quote)
if base_upper in @valid_currencies_list and
quote_upper in @valid_currencies_list do
"#{base_upper}#{quote_upper}=X"
else
# Not valid currencies, use as-is
ticker
end
else
# Not 3-letter codes
ticker
end
_ ->
# Not AAA/BBB format, use as-is
ticker
end
end
# Finds a date with available data (avoids weekends)
defp get_fallback_dates(target_date, max_days_back \\ 7) do
0..(max_days_back - 1)
|> Enum.map(fn days ->
Date.add(target_date, -days)
end)
|> Enum.filter(fn date ->
# Monday=1, Friday=5
Date.day_of_week(date) < 6
end)
end
# Internal utility function to fetch raw prices with temporal data
defp get_price_with_time(opts) do
ticker = Keyword.fetch!(opts, :ticker)
date_str = Keyword.get(opts, :date)
# Convert forex pairs to Yahoo Finance format
yahoo_ticker = convert_forex_format(ticker)
case date_str do
nil ->
get_current_price_with_time_meta(yahoo_ticker)
date_str ->
get_historical_price_with_time_meta(yahoo_ticker, date_str)
end
end
# Legacy function for conversion rates (simpler interface)
defp get_price(opts) do
ticker = Keyword.fetch!(opts, :ticker)
date_str = Keyword.get(opts, :date)
with_currency = Keyword.get(opts, :with_currency, false)
# Convert forex pairs to Yahoo Finance format
yahoo_ticker = convert_forex_format(ticker)
result =
case date_str do
nil ->
get_current_price_with_meta(yahoo_ticker, with_currency)
date_str ->
get_historical_price_with_meta(yahoo_ticker, date_str, with_currency)
end
result
end
@doc """
Fetches asset price with optional currency conversion
Parameters:
- symbol - Exact ticker (e.g. "BTC-USD", "AAPL", "EURCHF=X") (required)
Options:
- :to - Target currency (e.g. "CHF", "EUR") (optional, default: native currency)
- :date - Date struct (e.g. ~D[2024-12-31]) (optional)
Ticker formats:
- Crypto: "BTC-USD", "ETH-USD"
- Stocks: "AAPL", "TSLA", "GOOGL"
- Forex: "EURCHF=X", "GBPUSD=X"
## Examples
# Bitcoin in USD (default)
iex(1)> {:ok, quote} = YfQuote.get_quote("BTC-USD")
{:ok,
%YfQuote.Quote{
price: 109409.086,
currency: "USD",
symbol: "BTC-USD",
datetime: ~U[2025-09-04 16:42:00Z],
market_timezone: "UTC"
}}
# Bitcoin in CHF
iex(1)> {:ok, quote} = YfQuote.get_quote("BTC-USD", to: "CHF")
{:ok,
%YfQuote.Quote{
price: 88227.4869504,
currency: "CHF",
symbol: "BTC-USD",
datetime: ~U[2025-09-04 16:42:00Z],
market_timezone: "UTC"
}}
# Apple stock
iex(1)> {:ok, quote} = YfQuote.get_quote("AAPL")
{:ok,
%YfQuote.Quote{
price: 237.05,
currency: "USD",
symbol: "AAPL",
datetime: ~U[2025-09-04 16:43:52Z],
market_timezone: "America/New_York"
}}
# Historical price
iex(1)> YfQuote.get_quote("BTC-USD", date: ~D[2024-12-31])
{:ok,
%YfQuote.Quote{
price: 93429.203125,
currency: "USD",
symbol: "BTC-USD",
datetime: ~U[2024-12-31 23:59:59Z],
market_timezone: "UTC"
}}
# Date too old
iex(1)> YfQuote.get_quote("BTC-USD", date: ~D[2014-08-01])
{:error, :no_data}
Returns: {:ok, %Quote{}} | {:error, reason}
Error reasons:
- :no_data - No data available
- :invalid_symbol - Ticker not recognized
- :conversion_failed - Currency conversion failed
"""
@spec get_quote(String.t(), keyword()) ::
{:ok, Quote.t()} | {:error, :no_data | :invalid_symbol | :conversion_failed}
def get_quote(symbol, opts \\ []) do
target_currency = Keyword.get(opts, :to)
date = Keyword.get(opts, :date)
date_str = if date, do: Date.to_iso8601(date), else: nil
case get_base_price_and_currency_with_time(symbol, date_str) do
{:invalid_symbol, _, _, _} ->
handle_invalid_symbol_error(symbol, date)
{:no_data, _, _, _} ->
{:error, :no_data}
{nil, _, _, _} ->
handle_nil_price_error(symbol, date)
{base_price, native_currency, datetime, timezone} ->
build_quote_with_conversion(
symbol,
base_price,
native_currency,
target_currency,
datetime,
timezone,
date_str
)
end
end
# Handle invalid symbol error with historical date fallback
defp handle_invalid_symbol_error(symbol, date) do
if date do
case get_base_price_and_currency_with_time(symbol, nil) do
{price, _, _, _} when is_number(price) -> {:error, :no_data}
_ -> {:error, :invalid_symbol}
end
else
{:error, :invalid_symbol}
end
end
# Handle nil price error with fallback logic
defp handle_nil_price_error(symbol, date) do
if date do
case get_base_price_and_currency_with_time(symbol, nil) do
{:invalid_symbol, _, _, _} -> {:error, :invalid_symbol}
{price, _, _, _} when is_number(price) -> {:error, :no_data}
_ -> {:error, :invalid_symbol}
end
else
{:error, :invalid_symbol}
end
end
# Build quote with optional currency conversion
defp build_quote_with_conversion(
symbol,
base_price,
native_currency,
target_currency,
datetime,
timezone,
date_str
) do
if target_currency && target_currency != native_currency do
apply_currency_conversion(
symbol,
base_price,
native_currency,
target_currency,
datetime,
timezone,
date_str
)
else
build_quote(symbol, base_price, native_currency, datetime, timezone)
end
end
# Apply currency conversion to quote
defp apply_currency_conversion(
symbol,
base_price,
native_currency,
target_currency,
datetime,
timezone,
date_str
) do
Process.sleep(@rate_limit_delay_ms)
case get_conversion_rate(native_currency, target_currency, date_str) do
nil ->
{:error, :conversion_failed}
rate ->
converted_price = base_price * rate
build_quote(symbol, converted_price, target_currency, datetime, timezone)
end
end
# Build final quote structure
@spec build_quote(String.t(), float(), String.t(), DateTime.t(), String.t() | nil) ::
{:ok, Quote.t()}
defp build_quote(symbol, price, currency, datetime, timezone) do
quote = %Quote{
price: price,
currency: currency,
symbol: symbol,
datetime: datetime,
market_timezone: timezone
}
{:ok, quote}
end
# Gets base price, native currency, and temporal data for a symbol
defp get_base_price_and_currency_with_time(symbol, date_str) do
# Special case: USD is always worth 1 USD
if symbol == "USD" do
datetime =
if date_str do
# Historical: use date at midnight UTC
{:ok, date} = Date.from_iso8601(date_str)
DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
else
# Current: use current time
DateTime.utc_now()
end
{1.0, "USD", datetime, nil}
else
# Use ticker as is, fetch with temporal metadata
case get_price_with_time(ticker: symbol, date: date_str) do
{price, currency, datetime, timezone} when price != nil ->
{price, currency, datetime, timezone}
_ ->
{nil, "USD", DateTime.utc_now(), nil}
end
end
end
# Gets conversion rate between two currencies
@spec get_conversion_rate(String.t(), String.t(), String.t() | nil) :: float() | nil
defp get_conversion_rate(from_currency, to_currency, date_str) do
cond do
from_currency == to_currency ->
1.0
from_currency == "USD" ->
# USD to other currency
get_price(ticker: "USD/#{to_currency}", date: date_str)
to_currency == "USD" ->
# Other currency to USD (inverse)
case get_price(ticker: "#{from_currency}/USD", date: date_str) do
nil -> nil
rate -> rate
end
true ->
# Conversion via USD
case get_price(ticker: "#{from_currency}/USD", date: date_str) do
nil ->
nil
from_to_usd ->
case get_price(ticker: "USD/#{to_currency}", date: date_str) do
nil -> nil
usd_to_target -> from_to_usd * usd_to_target
end
end
end
end
# Gets current price with full temporal metadata
defp get_current_price_with_time_meta(yahoo_ticker) do
url = "#{@yahoo_finance_base_url}/#{yahoo_ticker}"
req_options = build_request_options_for_ticker(yahoo_ticker)
case Req.get(url, req_options) do
{:ok, %{status: 404}} ->
{:invalid_symbol, nil, nil, nil}
{:ok, %{status: 200, body: body}} ->
parse_current_price_response(body)
{:ok, %{status: 500}} ->
{nil, "USD", DateTime.utc_now(), nil}
{:ok, %{status: status}} when status >= 400 ->
{:invalid_symbol, nil, nil, nil}
{:error, _reason} ->
{nil, "USD", DateTime.utc_now(), nil}
_ ->
{nil, "USD", DateTime.utc_now(), nil}
end
end
# Build request options based on ticker type
defp build_request_options_for_ticker(yahoo_ticker) do
base_params = %{interval: "1d", range: "1d"}
if String.ends_with?(yahoo_ticker, "=X") do
[
params: base_params,
max_retries: @forex_retry_count,
retry_delay: fn n -> n * @forex_retry_delay_base_ms end
]
else
[params: base_params]
end
end
# Parse successful response from Yahoo Finance current price API
defp parse_current_price_response(body) do
error = get_in(body, ["chart", "error"])
result = get_in(body, ["chart", "result", Access.at(0)])
cond do
error != nil -> {:invalid_symbol, nil, nil, nil}
result == nil -> {:invalid_symbol, nil, nil, nil}
get_in(result, ["meta", "symbol"]) == nil -> {:invalid_symbol, nil, nil, nil}
true -> extract_price_data_from_result(result)
end
end
# Extract price and temporal data from Yahoo Finance result
defp extract_price_data_from_result(result) do
meta = get_in(result, ["meta"])
price = get_current_price_from_meta(meta, result)
if price do
currency = meta["currency"] || "USD"
market_time = extract_market_time(meta)
timezone = meta["exchangeTimezoneName"]
{price, currency, market_time, timezone}
else
{:no_data, "USD", DateTime.utc_now(), nil}
end
end
# Get current price from metadata with fallback to closing price
defp get_current_price_from_meta(meta, result) do
case meta["regularMarketPrice"] do
nil -> get_in(result, ["indicators", "quote", Access.at(0), "close", Access.at(-1)])
price -> price
end
end
# Extract market time from metadata
defp extract_market_time(meta) do
case meta["regularMarketTime"] do
nil -> DateTime.utc_now()
timestamp when is_integer(timestamp) -> DateTime.from_unix!(timestamp)
_ -> DateTime.utc_now()
end
end
# Build request options for historical data
defp build_historical_request_options(yahoo_ticker, start_timestamp, end_timestamp) do
base_params = %{period1: start_timestamp, period2: end_timestamp, interval: "1d"}
if String.ends_with?(yahoo_ticker, "=X") do
[
params: base_params,
max_retries: @forex_retry_count,
retry_delay: fn n -> n * @forex_retry_delay_base_ms end
]
else
[params: base_params]
end
end
# Legacy function for conversion rates
defp get_current_price_with_meta(yahoo_ticker, with_currency) do
case get_current_price_with_time_meta(yahoo_ticker) do
{price, currency, _datetime, _timezone} when is_number(price) ->
if with_currency, do: {price, currency}, else: price
_ ->
nil
end
end
# Gets historical price with temporal metadata
defp get_historical_price_with_time_meta(yahoo_ticker, date_str) do
case Date.from_iso8601(date_str) do
{:ok, target_date} ->
# Try different dates in case of weekends/holidays
Enum.find_value(get_fallback_dates(target_date), fn test_date ->
fetch_historical_price_with_time_meta(yahoo_ticker, test_date)
end) || fetch_price_range_with_time_meta(yahoo_ticker, target_date)
{:error, _} ->
# Invalid date, return nil
{nil, "USD", DateTime.utc_now(), nil}
end
end
# Legacy function for conversion rates
defp get_historical_price_with_meta(yahoo_ticker, date_str, with_currency) do
case get_historical_price_with_time_meta(yahoo_ticker, date_str) do
{price, currency, _datetime, _timezone} when price != nil ->
if with_currency, do: {price, currency}, else: price
_ ->
nil
end
end
# Fetches historical price with temporal metadata for specific date
defp fetch_historical_price_with_time_meta(yahoo_ticker, date) do
start_timestamp = date |> DateTime.new!(~T[00:00:00]) |> DateTime.to_unix()
end_timestamp = date |> Date.add(1) |> DateTime.new!(~T[00:00:00]) |> DateTime.to_unix()
url = "#{@yahoo_finance_base_url}/#{yahoo_ticker}"
req_options = build_historical_request_options(yahoo_ticker, start_timestamp, end_timestamp)
case Req.get(url, req_options) do
{:ok, %{status: 404}} ->
{:invalid_symbol, nil, nil, nil}
{:ok, %{status: 200, body: body}} ->
error = get_in(body, ["chart", "error"])
result = get_in(body, ["chart", "result", Access.at(0)])
cond do
# Yahoo returned explicit error
error != nil ->
{:invalid_symbol, nil, nil, nil}
# No result at all
result == nil ->
{:invalid_symbol, nil, nil, nil}
# No essential metadata (likely invalid ticker like "APPL")
get_in(result, ["meta", "symbol"]) == nil ->
{:invalid_symbol, nil, nil, nil}
# Valid ticker, try to get price
true ->
price = get_in(result, ["indicators", "quote", Access.at(0), "close", Access.at(0)])
case price do
price when is_number(price) ->
currency = get_in(result, ["meta", "currency"]) || "USD"
timezone = get_in(result, ["meta", "exchangeTimezoneName"])
# Historical prices are at market close, use end of day
datetime = DateTime.new!(date, ~T[23:59:59], "Etc/UTC")
{price, currency, datetime, timezone}
_ ->
# Valid symbol but no data for this date
{:no_data, "USD", DateTime.utc_now(), nil}
end
end
{:ok, %{status: 500}} ->
# Server error - likely temporary issue
{nil, "USD", DateTime.utc_now(), nil}
{:ok, %{status: status}} when status >= 400 ->
{:invalid_symbol, nil, nil, nil}
{:error, _reason} ->
# Network or other error
{nil, "USD", DateTime.utc_now(), nil}
_ ->
{nil, "USD", DateTime.utc_now(), nil}
end
end
# Fetches price range with temporal metadata (fallback for weekends)
defp fetch_price_range_with_time_meta(yahoo_ticker, target_date) do
start_date = Date.add(target_date, -7)
end_date = Date.add(target_date, 1)
start_timestamp = start_date |> DateTime.new!(~T[00:00:00]) |> DateTime.to_unix()
end_timestamp = end_date |> DateTime.new!(~T[00:00:00]) |> DateTime.to_unix()
url = "#{@yahoo_finance_base_url}/#{yahoo_ticker}"
req_options = build_historical_request_options(yahoo_ticker, start_timestamp, end_timestamp)
case Req.get(url, req_options) do
{:ok, %{status: 404}} ->
{:invalid_symbol, nil, nil, nil}
{:ok, %{status: 200, body: body}} ->
error = get_in(body, ["chart", "error"])
result = get_in(body, ["chart", "result", Access.at(0)])
cond do
# Yahoo returned explicit error
error != nil ->
{:invalid_symbol, nil, nil, nil}
# No result at all
result == nil ->
{:invalid_symbol, nil, nil, nil}
# No essential metadata (likely invalid ticker like "APPL")
get_in(result, ["meta", "symbol"]) == nil ->
{:invalid_symbol, nil, nil, nil}
# Valid ticker, try to get price
true ->
prices = get_in(result, ["indicators", "quote", Access.at(0), "close"])
case prices do
prices when is_list(prices) ->
# Take the last non-nil price
price =
prices
|> Enum.reverse()
|> Enum.find(& &1)
if price do
currency = get_in(result, ["meta", "currency"]) || "USD"
timezone = get_in(result, ["meta", "exchangeTimezoneName"])
# Use target date at market close
datetime = DateTime.new!(target_date, ~T[23:59:59], "Etc/UTC")
{price, currency, datetime, timezone}
else
{:no_data, "USD", DateTime.utc_now(), nil}
end
_ ->
{:no_data, "USD", DateTime.utc_now(), nil}
end
end
{:ok, %{status: 500}} ->
# Server error - likely temporary issue
{nil, "USD", DateTime.utc_now(), nil}
{:ok, %{status: status}} when status >= 400 ->
{:invalid_symbol, nil, nil, nil}
{:error, _reason} ->
# Network or other error
{nil, "USD", DateTime.utc_now(), nil}
_ ->
{nil, "USD", DateTime.utc_now(), nil}
end
end
@doc """
Fetches asset price (bang version, raises exception on error)
Same parameters as get_quote/2 but returns the Quote directly or raises an exception.
## Examples
# Success case
iex> quote = YfQuote.get_quote!("BTC-USD")
iex> is_struct(quote, YfQuote.Quote)
true
# Error case (raises exception)
YfQuote.get_quote!("INVALID_TICKER")
# ** (RuntimeError) Failed to get quote: invalid_symbol
"""
@spec get_quote!(String.t(), keyword()) :: Quote.t()
def get_quote!(symbol, opts \\ []) do
case get_quote(symbol, opts) do
{:ok, quote} -> quote
{:error, reason} -> raise "Failed to get quote: #{reason}"
end
end
end