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