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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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).

Planned follow-ups (not yet implemented):

- dividend history (`get_dividend_history/2`)
- symbol search (`search/2`)
- in-memory caching with TTL

Expand Down Expand Up @@ -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
Expand Down
171 changes: 137 additions & 34 deletions lib/yahoo_finance_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FROM><TO>=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

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion 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.2.0"
@version "0.3.0"
@source_url "https://github.com/fleveque/yahoo_finance_ex"

def project do
Expand Down
105 changes: 105 additions & 0 deletions test/yahoo_finance_ex_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading