From 4b5ee6773626f701e9c39122b0b8d752d4b802d7 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Thu, 11 Jun 2026 22:22:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3=20=E2=80=94=20get=5Fasset=5Fprofil?= =?UTF-8?q?e/1=20and=20get=5Fdividend=5Fhistory/2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two endpoints ported from the Ruby yahoo_finance_client: - get_asset_profile/1: sector + industry via quoteSummary's assetProfile module. Funds/ETFs (no profile, or a blank sector — Yahoo does both) are {:error, :not_found}, matching the Ruby client's nil. - get_dividend_history/2: per-payment history via the chart endpoint's events=div stream, date-sorted ascending, malformed entries dropped; :range option (default "2y" — enough to see a quarterly pattern twice). Payment-schedule inference stays consumer-side: the package ships data, not opinions. The 401-retry dance was about to be copy n.3 and n.4, so it's now a shared with_auth_retry/2 — get_quote and get_quotes refactored onto it (behavior unchanged, covered by the existing retry tests) — and the quote request generalized to an authed_get/3 all endpoints share. Co-Authored-By: Claude Fable 5 --- README.md | 15 ++- lib/yahoo_finance_ex.ex | 171 ++++++++++++++++++++++++++------- mix.exs | 2 +- test/yahoo_finance_ex_test.exs | 105 ++++++++++++++++++++ 4 files changed, 255 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d9d18f7..86b0c36 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ Elixir client for the Yahoo! Finance API. Handles Yahoo's cookie + CSRF crumb au ## Status -v0.2 surface: +v0.3 surface: - `get_quote/1` — single-symbol quote. - `get_quotes/1` — batched quote fetch (chunks of 50, returns a per-symbol result map). - `get_fx_rate/2` — FX rate between two ISO 4217 codes via the `=X` ticker convention. +- `get_asset_profile/1` — sector + industry via `quoteSummary`'s `assetProfile` module (v0.3). +- `get_dividend_history/2` — per-payment dividend history via the chart endpoint's `events=div` stream (v0.3). Planned follow-ups (not yet implemented): -- dividend history (`get_dividend_history/2`) - symbol search (`search/2`) - in-memory caching with TTL @@ -57,9 +58,17 @@ by_symbol["FAKE"] #=> {:error, :not_found} # unknown symbols come back in # FX rate {:ok, rate} = YahooFinanceEx.get_fx_rate("EUR", "USD") #=> {:ok, 1.08} {:ok, 1.0} = YahooFinanceEx.get_fx_rate("USD", "USD") # identity short-circuits + +# Sector / industry (funds and ETFs have none -> {:error, :not_found}) +{:ok, profile} = YahooFinanceEx.get_asset_profile("AAPL") +profile.sector #=> "Technology" + +# Dividend history (date-sorted; default range "2y") +{:ok, history} = YahooFinanceEx.get_dividend_history("KO") +hd(history) #=> %{date: ~D[2024-03-15], amount: 0.485} ``` -Top-level errors (for `get_quote` and `get_fx_rate`, plus aborted `get_quotes` calls) return `{:error, reason}` with one of: +Top-level errors (for the single-resource functions, plus aborted `get_quotes` calls) return `{:error, reason}` with one of: - `:not_found` — Yahoo returned no quote for the symbol/pair - `{:auth_failed, _}` — auth refresh failed after retries diff --git a/lib/yahoo_finance_ex.ex b/lib/yahoo_finance_ex.ex index 51c1f54..98304da 100644 --- a/lib/yahoo_finance_ex.ex +++ b/lib/yahoo_finance_ex.ex @@ -2,20 +2,24 @@ defmodule YahooFinanceEx do @moduledoc """ Elixir client for Yahoo! Finance. - v0.2 surface: + v0.3 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 `=X` quote symbol. + * `get_asset_profile/1` — sector + industry via the `quoteSummary` + endpoint's `assetProfile` module (v0.3). + * `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. 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`. - Dividend history, symbol search, and an in-memory cache layer are - planned follow-ups. + Symbol search and an in-memory cache layer are planned follow-ups. ## Quickstart @@ -39,6 +43,8 @@ defmodule YahooFinanceEx do alias YahooFinanceEx.{Quote, Session} @quote_path "/v7/finance/quote" + @quote_summary_path "/v10/finance/quoteSummary" + @chart_path "/v8/finance/chart" @max_auth_retries 2 @batch_size 50 @@ -68,21 +74,12 @@ defmodule YahooFinanceEx do fetch_quote_with_retry(symbol, 0) end - defp fetch_quote_with_retry(symbol, attempt) do - with {:ok, creds} <- Session.credentials(), - {:ok, body} <- do_quote_request(symbol, creds) do - parse_single_quote(body) - else - {:error, :unauthorized} when attempt < @max_auth_retries -> - Session.invalidate() - fetch_quote_with_retry(symbol, attempt + 1) - - {:error, :unauthorized} -> - {:error, {:auth_failed, :max_retries_exceeded}} - - {:error, _} = err -> - err - 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 @@ -124,21 +121,12 @@ defmodule YahooFinanceEx do end) end - defp fetch_batch_with_retry(symbols, attempt) do - with {:ok, creds} <- Session.credentials(), - {:ok, body} <- do_quote_request(Enum.join(symbols, ","), creds) do - {:ok, parse_batch_quote(body, symbols)} - else - {:error, :unauthorized} when attempt < @max_auth_retries -> - Session.invalidate() - fetch_batch_with_retry(symbols, attempt + 1) - - {:error, :unauthorized} -> - {:error, {:auth_failed, :max_retries_exceeded}} - - {:error, _} = err -> - err - 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 @@ -176,13 +164,128 @@ defmodule YahooFinanceEx do end end + ## get_asset_profile/1 + + @doc """ + Fetches the sector and industry for a ticker via Yahoo's + `quoteSummary` endpoint (`assetProfile` module). + + Returns `{:ok, %{sector: sector, industry: industry}}` (industry 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). + """ + @spec get_asset_profile(String.t()) :: + {:ok, %{sector: String.t(), industry: 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: profile["industry"]}} + + _missing_or_blank -> + {:error, :not_found} + end + end + + ## 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: [] + + ## 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: [symbols: symbols_param, crumb: creds.crumb], + params: params, headers: [ {"user-agent", Session.user_agent()}, {"cookie", creds.cookie} diff --git a/mix.exs b/mix.exs index 3017feb..f1aaef4 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule YahooFinanceEx.MixProject do use Mix.Project - @version "0.2.0" + @version "0.3.0" @source_url "https://github.com/fleveque/yahoo_finance_ex" def project do diff --git a/test/yahoo_finance_ex_test.exs b/test/yahoo_finance_ex_test.exs index 4b308f4..cd8a37b 100644 --- a/test/yahoo_finance_ex_test.exs +++ b/test/yahoo_finance_ex_test.exs @@ -177,6 +177,111 @@ defmodule YahooFinanceExTest do ## Helpers + describe "get_asset_profile/1" do + test "returns sector + industry from the assetProfile module" do + stub_yahoo(fn conn -> + case {conn.host, conn.request_path} do + {"fc.yahoo.com", _} -> + cookie(conn) + + {"query1.finance.yahoo.com", "/v1/test/getcrumb"} -> + Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") + + {"query1.finance.yahoo.com", "/v10/finance/quoteSummary/AAPL"} -> + Req.Test.json(conn, %{ + "quoteSummary" => %{ + "result" => [ + %{ + "assetProfile" => %{ + "sector" => "Technology", + "industry" => "Consumer Electronics" + } + } + ] + } + }) + end + end) + + assert {:ok, %{sector: "Technology", industry: "Consumer Electronics"}} = + YahooFinanceEx.get_asset_profile("AAPL") + end + + test "funds/ETFs (no profile or blank sector) are :not_found" do + stub_yahoo(fn conn -> + case {conn.host, conn.request_path} do + {"fc.yahoo.com", _} -> + cookie(conn) + + {"query1.finance.yahoo.com", "/v1/test/getcrumb"} -> + Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") + + {"query1.finance.yahoo.com", "/v10/finance/quoteSummary/VWCE.DE"} -> + Req.Test.json(conn, %{ + "quoteSummary" => %{"result" => [%{"assetProfile" => %{"sector" => ""}}]} + }) + end + end) + + assert {:error, :not_found} = YahooFinanceEx.get_asset_profile("VWCE.DE") + end + end + + describe "get_dividend_history/2" do + test "returns date-sorted entries from the chart events stream" do + stub_yahoo(fn conn -> + case {conn.host, conn.request_path} do + {"fc.yahoo.com", _} -> + cookie(conn) + + {"query1.finance.yahoo.com", "/v1/test/getcrumb"} -> + Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") + + {"query1.finance.yahoo.com", "/v8/finance/chart/KO"} -> + assert conn.query_params["events"] == "div" + + Req.Test.json(conn, %{ + "chart" => %{ + "result" => [ + %{ + "events" => %{ + "dividends" => %{ + # Deliberately unsorted; one malformed entry dropped. + "1717000000" => %{"date" => 1_717_000_000, "amount" => 0.485}, + "1709000000" => %{"date" => 1_709_000_000, "amount" => 0.485}, + "bad" => %{"date" => nil, "amount" => 0.485} + } + } + } + ] + } + }) + end + end) + + assert {:ok, [first, second]} = YahooFinanceEx.get_dividend_history("KO") + assert first.amount == 0.485 + assert Date.compare(first.date, second.date) == :lt + end + + test "no dividend events is {:ok, []}" do + stub_yahoo(fn conn -> + case {conn.host, conn.request_path} do + {"fc.yahoo.com", _} -> + cookie(conn) + + {"query1.finance.yahoo.com", "/v1/test/getcrumb"} -> + Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") + + {"query1.finance.yahoo.com", "/v8/finance/chart/GROW"} -> + Req.Test.json(conn, %{"chart" => %{"result" => [%{"events" => %{}}]}}) + end + end) + + assert {:ok, []} = YahooFinanceEx.get_dividend_history("GROW") + end + end + defp stub_yahoo(fun) do Req.Test.stub(YahooFinanceEx.HTTPStub, fun) # The Session GenServer (started by the package's Application) lives