diff --git a/CHANGELOG.md b/CHANGELOG.md index 28343dc..217643a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-06-12 + +### Added + +- `YahooFinanceEx.search/2` — free-text ticker/company autocomplete via + Yahoo's `/v1/finance/search` endpoint. Returns `{:ok, results}` with + `%{symbol:, name:, exchange:, type:}` entries in Yahoo's relevance + order; `type` is Yahoo's `quoteType` so callers can filter instrument + kinds. Blank queries short-circuit to `{:ok, []}`. + +## [0.3.0] - 2026-06-11 + +_(Entry backfilled — 0.3.0 shipped without a changelog entry.)_ + +### Added + +- `YahooFinanceEx.get_asset_profile/1` — sector + industry via the + `quoteSummary` endpoint's `assetProfile` module. +- `YahooFinanceEx.get_dividend_history/2` — per-payment dividend + history via the chart endpoint's `events=div` stream; the raw + material for payment-schedule inference. Accepts `:range` (default + `"2y"`). + ## [0.2.0] - 2026-06-08 ### Added diff --git a/README.md b/README.md index 86b0c36..2654bed 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,17 @@ Elixir client for the Yahoo! Finance API. Handles Yahoo's cookie + CSRF crumb au ## Status -v0.3 surface: +v0.4 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). +- `search/2` — free-text ticker/company autocomplete via the `search` endpoint (v0.4). Planned follow-ups (not yet implemented): -- symbol search (`search/2`) - in-memory caching with TTL ## Installation diff --git a/lib/yahoo_finance_ex.ex b/lib/yahoo_finance_ex.ex index 98304da..a321d75 100644 --- a/lib/yahoo_finance_ex.ex +++ b/lib/yahoo_finance_ex.ex @@ -2,7 +2,7 @@ defmodule YahooFinanceEx do @moduledoc """ Elixir client for Yahoo! Finance. - v0.3 surface: + v0.4 surface: * `get_quote/1` — single-symbol quote. * `get_quotes/1` — batched quote fetch (up to 50 symbols per HTTP call; @@ -14,12 +14,14 @@ defmodule YahooFinanceEx do * `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). 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`. - Symbol search and an in-memory cache layer are planned follow-ups. + An in-memory cache layer is a planned follow-up. ## Quickstart @@ -45,6 +47,7 @@ defmodule YahooFinanceEx do @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 @@ -58,6 +61,14 @@ defmodule YahooFinanceEx do @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 """ @@ -256,6 +267,69 @@ defmodule YahooFinanceEx do defp parse_dividend_entry(_malformed), 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: [] + ## Auth-retry wrapper — Yahoo invalidates sessions occasionally, so ## every endpoint retries once on 401 with fresh credentials. diff --git a/mix.exs b/mix.exs index f1aaef4..e0d1041 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule YahooFinanceEx.MixProject do use Mix.Project - @version "0.3.0" + @version "0.4.0" @source_url "https://github.com/fleveque/yahoo_finance_ex" def project do @@ -36,8 +36,8 @@ defmodule YahooFinanceEx.MixProject do defp description do "Elixir client for the Yahoo! Finance API. Handles the cookie + " <> - "CSRF crumb auth flow transparently. Single + batched quote fetch " <> - "and FX rates; dividend history and search planned." + "CSRF crumb auth flow transparently. Single + batched quotes, FX " <> + "rates, asset profiles, dividend history, and symbol search." end defp package do diff --git a/test/yahoo_finance_ex_test.exs b/test/yahoo_finance_ex_test.exs index cd8a37b..7b51f71 100644 --- a/test/yahoo_finance_ex_test.exs +++ b/test/yahoo_finance_ex_test.exs @@ -282,6 +282,69 @@ defmodule YahooFinanceExTest do end end + describe "search/2" do + test "returns parsed matches in Yahoo's order" 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", "/v1/finance/search"} -> + assert conn.params["q"] == "coca cola" + + Req.Test.json(conn, %{ + "quotes" => [ + %{ + "symbol" => "KO", + "shortname" => "Coca-Cola Company (The)", + "exchDisp" => "NYSE", + "quoteType" => "EQUITY" + }, + %{ + "symbol" => "CCEP", + "longname" => "Coca-Cola Europacific Partners PLC", + "exchange" => "NMS", + "quoteType" => "EQUITY" + }, + # No symbol → dropped. + %{"shortname" => "Mystery"} + ] + }) + end + end) + + assert {:ok, [ko, ccep]} = YahooFinanceEx.search("coca cola") + assert %{symbol: "KO", name: "Coca-Cola Company (The)", exchange: "NYSE"} = ko + assert %{symbol: "CCEP", name: "Coca-Cola Europacific Partners PLC", exchange: "NMS"} = ccep + assert ko.type == "EQUITY" + end + + test "blank query short-circuits to {:ok, []} without HTTP" do + # No stub installed — any HTTP call would crash the test. + assert {:ok, []} = YahooFinanceEx.search(" ") + end + + test "no matches 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", "/v1/finance/search"} -> + Req.Test.json(conn, %{"quotes" => []}) + end + end) + + assert {:ok, []} = YahooFinanceEx.search("zzzzzz") + end + end + defp stub_yahoo(fun) do Req.Test.stub(YahooFinanceEx.HTTPStub, fun) # The Session GenServer (started by the package's Application) lives