Skip to main content

lib/yahoo_finance_ex.ex

defmodule YahooFinanceEx do
  @moduledoc """
  Elixir client for Yahoo! Finance.

  v0.7 surface:

    * `get_quote/1` — single-symbol quote.
    * `get_quotes/1` — batched quote fetch (up to 50 symbols per HTTP call;
      this function transparently batches larger lists).
    * `get_fx_rate/2` — current FX rate between two ISO 4217 currency codes
      via Yahoo's `<FROM><TO>=X` quote symbol.
    * `get_asset_profile/1` — company profile (sector, industry, website,
      description) via the `quoteSummary` endpoint's `assetProfile` module
      (v0.3; website + description added in v0.6).
    * `get_financial_data/1` — leverage figures (total debt, debt/equity,
      current & quick ratio, cash, EBITDA) via the `quoteSummary`
      endpoint's `financialData` module (v0.5).
    * `get_dividend_history/2` — per-payment dividend history via the
      chart endpoint's `events=div` stream (v0.3); the raw material for
      payment-schedule inference.
    * `search/2` — free-text ticker/company autocomplete via the
      `search` endpoint (v0.4).
    * `get_news/2` — recent news headlines via the `search` endpoint's
      `news` stream (v0.6).
    * `get_price_history/2` — monthly closing prices via the chart endpoint
      (the price series beside the dividend stream) (v0.7).

  All paths go through `YahooFinanceEx.Session` to handle the cookie + CSRF
  crumb auth dance, and through `Req` for HTTP — so tests can stub the
  whole thing with `Req.Test`.

  An in-memory cache layer is a planned follow-up.

  ## Quickstart

      {:ok, quote} = YahooFinanceEx.get_quote("AAPL")

      {:ok, by_symbol} = YahooFinanceEx.get_quotes(["AAPL", "MSFT", "GOOG"])
      by_symbol["AAPL"]
      #=> {:ok, %YahooFinanceEx.Quote{symbol: "AAPL", ...}}

      {:ok, rate} = YahooFinanceEx.get_fx_rate("EUR", "USD")
      #=> {:ok, 1.08}

  ## Notes

  Yahoo's API is unofficial. Endpoints, auth requirements, and response
  shapes can change without notice. Two auth strategies are tried in
  order before erroring; sessions live for 60 seconds before being
  re-fetched.
  """

  alias YahooFinanceEx.{Quote, Session}

  @quote_path "/v7/finance/quote"
  @quote_summary_path "/v10/finance/quoteSummary"
  @chart_path "/v8/finance/chart"
  @search_path "/v1/finance/search"
  @max_auth_retries 2
  @batch_size 50

  @typedoc "Errors returned by the public functions."
  @type error ::
          {:auth_failed, term()}
          | {:http_status, non_neg_integer()}
          | {:transport, term()}
          | :not_found

  @typedoc "Per-symbol result inside a batched `get_quotes/1` response."
  @type per_symbol_result :: {:ok, Quote.t()} | {:error, :not_found}

  @typedoc "One match returned by `search/2`."
  @type search_result :: %{
          symbol: String.t(),
          name: String.t(),
          exchange: String.t() | nil,
          type: String.t() | nil
        }

  ## get_quote/1

  @doc """
  Fetches a single stock quote.

  Returns `{:ok, %YahooFinanceEx.Quote{}}` on success, or `{:error, reason}`
  with one of the `t:error/0` shapes on failure.

  Retries once on transient auth errors (Yahoo invalidates sessions
  occasionally); deeper failures bubble up.
  """
  @spec get_quote(String.t()) :: {:ok, Quote.t()} | {:error, error()}
  def get_quote(symbol) when is_binary(symbol) do
    fetch_quote_with_retry(symbol, 0)
  end

  defp fetch_quote_with_retry(symbol, _attempt) do
    with_auth_retry(fn creds ->
      with {:ok, body} <- do_quote_request(symbol, creds) do
        parse_single_quote(body)
      end
    end)
  end

  defp parse_single_quote(body) when is_map(body) do
    case get_in(body, ["quoteResponse", "result"]) do
      [first | _] when is_map(first) -> {:ok, Quote.from_yahoo(first)}
      _ -> {:error, :not_found}
    end
  end

  ## get_quotes/1

  @doc """
  Fetches quotes for many symbols in one or more batched HTTP calls.

  Returns `{:ok, results_map}` where `results_map` is `%{symbol =>
  {:ok, Quote.t()} | {:error, :not_found}}` — i.e. each requested symbol
  is present in the map, mapped to its individual result. Symbols Yahoo
  doesn't recognize come back as `{:error, :not_found}`.

  Top-level errors (`{:auth_failed, _}`, `{:transport, _}`, etc.) abort
  the whole call and are returned as `{:error, reason}`.

  Symbols are batched in groups of #{@batch_size} (Yahoo's per-request
  ceiling). Duplicates and empty lists are tolerated.
  """
  @spec get_quotes([String.t()]) ::
          {:ok, %{String.t() => per_symbol_result()}} | {:error, error()}
  def get_quotes([]), do: {:ok, %{}}

  def get_quotes(symbols) when is_list(symbols) do
    symbols
    |> Enum.uniq()
    |> Enum.chunk_every(@batch_size)
    |> Enum.reduce_while({:ok, %{}}, fn batch, {:ok, acc} ->
      case fetch_batch_with_retry(batch, 0) do
        {:ok, batch_results} -> {:cont, {:ok, Map.merge(acc, batch_results)}}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp fetch_batch_with_retry(symbols, _attempt) do
    with_auth_retry(fn creds ->
      with {:ok, body} <- do_quote_request(Enum.join(symbols, ","), creds) do
        {:ok, parse_batch_quote(body, symbols)}
      end
    end)
  end

  defp parse_batch_quote(body, requested_symbols) when is_map(body) do
    found =
      body
      |> get_in(["quoteResponse", "result"])
      |> List.wrap()
      |> Map.new(fn raw -> {raw["symbol"], {:ok, Quote.from_yahoo(raw)}} end)

    Enum.reduce(requested_symbols, found, fn sym, acc ->
      Map.put_new(acc, sym, {:error, :not_found})
    end)
  end

  ## get_fx_rate/2

  @doc """
  Fetches the current FX rate between two ISO 4217 currency codes — one
  unit of `from` expressed in `to`.

  Returns `{:ok, 1.0}` for identity pairs without hitting the API.
  Returns `{:ok, rate}` (a float) on success, or `{:error, reason}` on
  failure (including `:not_found` when Yahoo has no quote for the pair).
  """
  @spec get_fx_rate(String.t(), String.t()) :: {:ok, float()} | {:error, error()}
  def get_fx_rate(currency, currency) when is_binary(currency), do: {:ok, 1.0}

  def get_fx_rate(from, to) when is_binary(from) and is_binary(to) do
    pair = String.upcase(from) <> String.upcase(to) <> "=X"

    case get_quote(pair) do
      {:ok, %Quote{price: price}} when is_number(price) -> {:ok, price * 1.0}
      {:ok, _} -> {:error, :not_found}
      {:error, _} = err -> err
    end
  end

  ## get_asset_profile/1

  @doc """
  Fetches the company profile for a ticker via Yahoo's `quoteSummary`
  endpoint (`assetProfile` module).

  Returns `{:ok, %{sector:, industry:, website:, description:}}` — `industry`,
  `website` and `description` may be nil — or `{:error, :not_found}` for funds,
  ETFs, and any symbol where Yahoo exposes no asset profile (a blank sector
  counts as none — matching the Ruby client's behavior). `description` is
  Yahoo's `longBusinessSummary` (English).
  """
  @spec get_asset_profile(String.t()) ::
          {:ok,
           %{
             sector: String.t(),
             industry: String.t() | nil,
             website: String.t() | nil,
             description: String.t() | nil
           }}
          | {:error, error()}
  def get_asset_profile(symbol) when is_binary(symbol) do
    with_auth_retry(fn creds ->
      url = creds.base_url <> @quote_summary_path <> "/" <> URI.encode(symbol)

      with {:ok, body} <-
             authed_get(url, [modules: "assetProfile", crumb: creds.crumb], creds) do
        parse_asset_profile(body)
      end
    end)
  end

  defp parse_asset_profile(body) when is_map(body) do
    case get_in(body, ["quoteSummary", "result", Access.at(0), "assetProfile"]) do
      %{"sector" => sector} = profile when is_binary(sector) and sector != "" ->
        {:ok,
         %{
           sector: sector,
           industry: blank_to_nil(profile["industry"]),
           website: blank_to_nil(profile["website"]),
           description: blank_to_nil(profile["longBusinessSummary"])
         }}

      _missing_or_blank ->
        {:error, :not_found}
    end
  end

  defp blank_to_nil(value) when is_binary(value) and value != "", do: value
  defp blank_to_nil(_absent_or_blank), do: nil

  ## get_financial_data/1

  @doc """
  Fetches key leverage / balance-sheet figures for a ticker via the
  `quoteSummary` endpoint (`financialData` module).

  Returns `{:ok, %{total_debt, debt_to_equity, current_ratio, quick_ratio,
  total_cash, ebitda}}` — each value a float or nil — or `{:error, :not_found}`
  when Yahoo exposes no `financialData` (common for funds/ETFs and many
  non-US tickers). `debt_to_equity` is Yahoo's percentage figure
  (e.g. `151.4` = 151.4%).
  """
  @spec get_financial_data(String.t()) ::
          {:ok,
           %{
             total_debt: float() | nil,
             debt_to_equity: float() | nil,
             current_ratio: float() | nil,
             quick_ratio: float() | nil,
             total_cash: float() | nil,
             ebitda: float() | nil
           }}
          | {:error, error()}
  def get_financial_data(symbol) when is_binary(symbol) do
    with_auth_retry(fn creds ->
      url = creds.base_url <> @quote_summary_path <> "/" <> URI.encode(symbol)

      with {:ok, body} <-
             authed_get(url, [modules: "financialData", crumb: creds.crumb], creds) do
        parse_financial_data(body)
      end
    end)
  end

  defp parse_financial_data(body) when is_map(body) do
    case get_in(body, ["quoteSummary", "result", Access.at(0), "financialData"]) do
      data when is_map(data) ->
        {:ok,
         %{
           total_debt: fin_raw(data["totalDebt"]),
           debt_to_equity: fin_raw(data["debtToEquity"]),
           current_ratio: fin_raw(data["currentRatio"]),
           quick_ratio: fin_raw(data["quickRatio"]),
           total_cash: fin_raw(data["totalCash"]),
           ebitda: fin_raw(data["ebitda"])
         }}

      _missing ->
        {:error, :not_found}
    end
  end

  # quoteSummary numeric fields arrive as `%{"raw" => number, "fmt" => "..."}`.
  defp fin_raw(%{"raw" => n}) when is_number(n), do: n / 1
  defp fin_raw(_absent), do: nil

  ## get_dividend_history/2

  @doc """
  Fetches the per-payment dividend history for a ticker via the chart
  endpoint's `events=div` stream.

  Returns `{:ok, entries}` — each entry `%{date: Date.t(), amount:
  float}`, sorted ascending by date — or `{:ok, []}` when the symbol
  pays no dividends (or Yahoo reports none for the range). Consumers
  infer payment schedules (frequency, months) from these entries.

  Options:

    * `:range` — Yahoo range string, default `"2y"` (enough to see a
      quarterly pattern twice).
  """
  @spec get_dividend_history(String.t(), keyword()) ::
          {:ok, [%{date: Date.t(), amount: float()}]} | {:error, error()}
  def get_dividend_history(symbol, opts \\ []) when is_binary(symbol) do
    range = Keyword.get(opts, :range, "2y")

    with_auth_retry(fn creds ->
      url = creds.base_url <> @chart_path <> "/" <> URI.encode(symbol)

      with {:ok, body} <-
             authed_get(
               url,
               [range: range, interval: "1mo", events: "div", crumb: creds.crumb],
               creds
             ) do
        {:ok, parse_dividend_history(body)}
      end
    end)
  end

  defp parse_dividend_history(body) when is_map(body) do
    case get_in(body, ["chart", "result", Access.at(0), "events", "dividends"]) do
      %{} = dividends ->
        dividends
        |> Map.values()
        |> Enum.flat_map(&parse_dividend_entry/1)
        |> Enum.sort_by(& &1.date, Date)

      _none ->
        []
    end
  end

  defp parse_dividend_entry(%{"date" => unix, "amount" => amount})
       when is_integer(unix) and is_number(amount) and amount > 0 do
    case DateTime.from_unix(unix) do
      {:ok, datetime} -> [%{date: DateTime.to_date(datetime), amount: amount * 1.0}]
      {:error, _} -> []
    end
  end

  defp parse_dividend_entry(_malformed), do: []

  ## get_price_history/2

  @doc """
  Fetches the monthly closing-price history for a ticker via the chart
  endpoint (the price series alongside the dividend stream).

  Returns `{:ok, entries}` — each entry `%{date: Date.t(), close: float}`,
  sorted ascending by date, skipping months Yahoo reports as null — or
  `{:ok, []}` when the symbol has no price data. Consumers use it (paired
  with the dividend history) to build a historical yield band.

  Options:

    * `:range` — Yahoo range string, default `"6y"` (enough for a ~5-year
      yield band plus a buffer).
  """
  @spec get_price_history(String.t(), keyword()) ::
          {:ok, [%{date: Date.t(), close: float()}]} | {:error, error()}
  def get_price_history(symbol, opts \\ []) when is_binary(symbol) do
    range = Keyword.get(opts, :range, "6y")

    with_auth_retry(fn creds ->
      url = creds.base_url <> @chart_path <> "/" <> URI.encode(symbol)

      with {:ok, body} <-
             authed_get(url, [range: range, interval: "1mo", crumb: creds.crumb], creds) do
        {:ok, parse_price_history(body)}
      end
    end)
  end

  defp parse_price_history(body) when is_map(body) do
    result = get_in(body, ["chart", "result", Access.at(0)])
    timestamps = result && result["timestamp"]
    closes = result && get_in(result, ["indicators", "quote", Access.at(0), "close"])

    if is_list(timestamps) and is_list(closes) do
      timestamps
      |> Enum.zip(closes)
      |> Enum.flat_map(&parse_price_point/1)
      |> Enum.sort_by(& &1.date, Date)
    else
      []
    end
  end

  defp parse_price_point({unix, close}) when is_integer(unix) and is_number(close) do
    case DateTime.from_unix(unix) do
      {:ok, datetime} -> [%{date: DateTime.to_date(datetime), close: close * 1.0}]
      {:error, _} -> []
    end
  end

  # Null months (Yahoo sometimes reports a null close) and malformed points.
  defp parse_price_point(_skip), do: []

  ## search/2

  @doc """
  Searches Yahoo Finance for tickers matching a free-text query (a
  ticker fragment or a company name) via the `/v1/finance/search`
  autocomplete endpoint.

  Returns `{:ok, results}` — each result `%{symbol:, name:, exchange:,
  type:}`, in Yahoo's relevance order — or `{:ok, []}` for a blank
  query or no matches. `type` is Yahoo's `quoteType` (`"EQUITY"`,
  `"ETF"`, `"MUTUALFUND"`, `"INDEX"`, …) so callers can filter to the
  instruments they care about; `name` falls back `shortname` →
  `longname` → symbol.

  Options:

    * `:count` — max results to request, default 10.
  """
  @spec search(String.t(), keyword()) :: {:ok, [search_result()]} | {:error, error()}
  def search(query, opts \\ []) when is_binary(query) do
    count = Keyword.get(opts, :count, 10)

    case String.trim(query) do
      "" ->
        {:ok, []}

      normalized ->
        with_auth_retry(fn creds ->
          url = creds.base_url <> @search_path

          with {:ok, body} <-
                 authed_get(
                   url,
                   [q: normalized, quotesCount: count, newsCount: 0, crumb: creds.crumb],
                   creds
                 ) do
            {:ok, parse_search(body)}
          end
        end)
    end
  end

  defp parse_search(body) when is_map(body) do
    body
    |> Map.get("quotes")
    |> List.wrap()
    |> Enum.flat_map(&parse_search_quote/1)
  end

  defp parse_search_quote(%{"symbol" => symbol} = raw)
       when is_binary(symbol) and symbol != "" do
    [
      %{
        symbol: symbol,
        name: raw["shortname"] || raw["longname"] || symbol,
        exchange: raw["exchDisp"] || raw["exchange"],
        type: raw["quoteType"]
      }
    ]
  end

  defp parse_search_quote(_no_symbol), do: []

  ## get_news/2

  @doc """
  Fetches recent news headlines for a ticker via Yahoo's
  `/v1/finance/search` endpoint (its `news` stream).

  Returns `{:ok, items}` — each `%{title:, url:, publisher:, published_at:}`
  with `published_at` a UTC `DateTime` (or nil), most-recent first — or
  `{:ok, []}` when Yahoo returns no news.

  Options:

    * `:count` — max headlines to request, default 8.
  """
  @spec get_news(String.t(), keyword()) ::
          {:ok,
           [
             %{
               title: String.t(),
               url: String.t() | nil,
               publisher: String.t() | nil,
               published_at: DateTime.t() | nil
             }
           ]}
          | {:error, error()}
  def get_news(symbol, opts \\ []) when is_binary(symbol) do
    count = Keyword.get(opts, :count, 8)

    with_auth_retry(fn creds ->
      url = creds.base_url <> @search_path

      with {:ok, body} <-
             authed_get(
               url,
               [q: symbol, quotesCount: 0, newsCount: count, crumb: creds.crumb],
               creds
             ) do
        {:ok, parse_news(body)}
      end
    end)
  end

  defp parse_news(body) when is_map(body) do
    body
    |> Map.get("news")
    |> List.wrap()
    |> Enum.flat_map(&parse_news_item/1)
    |> Enum.sort_by(&news_sort_key/1, :desc)
  end

  defp parse_news_item(%{"title" => title} = raw) when is_binary(title) and title != "" do
    [
      %{
        title: title,
        url: blank_to_nil(raw["link"]),
        publisher: blank_to_nil(raw["publisher"]),
        published_at: parse_unix(raw["providerPublishTime"])
      }
    ]
  end

  defp parse_news_item(_no_title), do: []

  # Sort by publish time descending; undated items sink to the bottom.
  defp news_sort_key(%{published_at: %DateTime{} = dt}), do: DateTime.to_unix(dt)
  defp news_sort_key(_undated), do: 0

  defp parse_unix(secs) when is_integer(secs) do
    case DateTime.from_unix(secs) do
      {:ok, dt} -> dt
      {:error, _} -> nil
    end
  end

  defp parse_unix(_not_a_timestamp), do: nil

  ## Auth-retry wrapper — Yahoo invalidates sessions occasionally, so
  ## every endpoint retries once on 401 with fresh credentials.

  defp with_auth_retry(fun, attempt \\ 0) do
    with {:ok, creds} <- Session.credentials() do
      fun.(creds)
    end
    |> case do
      {:error, :unauthorized} when attempt < @max_auth_retries ->
        Session.invalidate()
        with_auth_retry(fun, attempt + 1)

      {:error, :unauthorized} ->
        {:error, {:auth_failed, :max_retries_exceeded}}

      other ->
        other
    end
  end

  ## Shared HTTP wrapper

  defp do_quote_request(symbols_param, creds) do
    url = creds.base_url <> @quote_path
    authed_get(url, [symbols: symbols_param, crumb: creds.crumb], creds)
  end

  defp authed_get(url, params, creds) do
    case YahooFinanceEx.HTTP.get(url,
           params: params,
           headers: [
             {"user-agent", Session.user_agent()},
             {"cookie", creds.cookie}
           ],
           receive_timeout: 10_000
         ) do
      {:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
        {:ok, body}

      {:ok, %Req.Response{status: 401}} ->
        {:error, :unauthorized}

      {:ok, %Req.Response{status: status}} ->
        {:error, {:http_status, status}}

      {:error, reason} ->
        {:error, {:transport, reason}}
    end
  end
end