Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FROM><TO>=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
Expand Down
78 changes: 76 additions & 2 deletions lib/yahoo_finance_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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 """
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions test/yahoo_finance_ex_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading