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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-06-08

### Added

- `YahooFinanceEx.get_quotes/1` — batched quote fetch for many symbols
in one HTTP call. Transparently chunks lists into batches of 50
(Yahoo's per-request ceiling). Returns `{:ok, %{symbol => result}}`
where each result is `{:ok, Quote.t()}` or `{:error, :not_found}`.
- `YahooFinanceEx.get_fx_rate/2` — current FX rate between two ISO 4217
currency codes via Yahoo's `<FROM><TO>=X` quote symbol. Short-circuits
identity pairs (`get_fx_rate("USD", "USD")` returns `{:ok, 1.0}`)
without hitting the API.

### Changed

- Package description tightened to reflect the v0.2 surface.

## [0.1.0] - 2026-06-01

### Added
Expand Down
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ Elixir client for the Yahoo! Finance API. Handles Yahoo's cookie + CSRF crumb au

## Status

v0.1 ships the smallest useful surface: **single-symbol quote fetch**. Planned follow-ups (none yet implemented):
v0.2 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.

Planned follow-ups (not yet implemented):

- batched quote fetch (`get_quotes/1`)
- FX rates (`get_fx_rate/2`)
- dividend history (`get_dividend_history/2`)
- symbol search (`search/2`)
- in-memory caching with TTL
Expand All @@ -23,7 +27,7 @@ v0.1 ships the smallest useful surface: **single-symbol quote fetch**. Planned f
```elixir
def deps do
[
{:yahoo_finance_ex, "~> 0.1"}
{:yahoo_finance_ex, "~> 0.2"}
]
end
```
Expand All @@ -41,22 +45,29 @@ end
## Usage

```elixir
# Single symbol
{:ok, quote} = YahooFinanceEx.get_quote("AAPL")

quote.symbol #=> "AAPL"
quote.price #=> 187.42
quote.currency #=> "USD"
quote.dividend_yield #=> 0.51
quote.ma200 #=> 175.00

# Batched (chunks into groups of 50 internally)
{:ok, by_symbol} = YahooFinanceEx.get_quotes(["AAPL", "MSFT", "GOOG"])
by_symbol["AAPL"] #=> {:ok, %YahooFinanceEx.Quote{...}}
by_symbol["FAKE"] #=> {:error, :not_found} # unknown symbols come back individually

# 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
```

Errors return `{:error, reason}` with one of:
Top-level errors (for `get_quote` and `get_fx_rate`, plus aborted `get_quotes` calls) return `{:error, reason}` with one of:

- `:not_found` — Yahoo returned an empty result for the symbol
- `:not_found` — Yahoo returned no quote for the symbol/pair
- `{:auth_failed, _}` — auth refresh failed after retries
- `{:http_status, status}` — non-200 HTTP status from Yahoo
- `{:transport, reason}` — network / transport error from Req

For `get_quotes/1`, partial failures (some symbols missing) surface inside the result map as `{:error, :not_found}` for those keys; the top-level call still returns `{:ok, map}`.

## Architecture

```
Expand Down
143 changes: 123 additions & 20 deletions lib/yahoo_finance_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@ defmodule YahooFinanceEx do
@moduledoc """
Elixir client for Yahoo! Finance.

v0.1 ships the smallest useful surface — a single-symbol quote fetch
via Yahoo's `/v7/finance/quote` endpoint, with the cookie + CSRF crumb
auth dance handled transparently by `YahooFinanceEx.Session`. Batched
quotes, FX rates, dividend history, and symbol search are planned
follow-ups.
v0.2 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.

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.

## Quickstart

{:ok, quote} = YahooFinanceEx.get_quote("AAPL")
quote.price
#=> 187.42

All HTTP calls go through [`Req`](https://hexdocs.pm/req), so tests can
stub responses via `Req.Test.stub/2`.
{: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

Expand All @@ -29,6 +40,7 @@ defmodule YahooFinanceEx do

@quote_path "/v7/finance/quote"
@max_auth_retries 2
@batch_size 50

@typedoc "Errors returned by the public functions."
@type error ::
Expand All @@ -37,6 +49,11 @@ defmodule YahooFinanceEx do
| {: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}

## get_quote/1

@doc """
Fetches a single stock quote.

Expand All @@ -53,8 +70,8 @@ defmodule YahooFinanceEx do

defp fetch_quote_with_retry(symbol, attempt) do
with {:ok, creds} <- Session.credentials(),
{:ok, body} <- do_get_quote(symbol, creds) do
parse_quote_body(body, symbol)
{:ok, body} <- do_quote_request(symbol, creds) do
parse_single_quote(body)
else
{:error, :unauthorized} when attempt < @max_auth_retries ->
Session.invalidate()
Expand All @@ -68,11 +85,104 @@ defmodule YahooFinanceEx do
end
end

defp do_get_quote(symbol, creds) do
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 {: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
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

## Shared HTTP wrapper

defp do_quote_request(symbols_param, creds) do
url = creds.base_url <> @quote_path

case YahooFinanceEx.HTTP.get(url,
params: [symbols: symbol, crumb: creds.crumb],
params: [symbols: symbols_param, crumb: creds.crumb],
headers: [
{"user-agent", Session.user_agent()},
{"cookie", creds.cookie}
Expand All @@ -92,11 +202,4 @@ defmodule YahooFinanceEx do
{:error, {:transport, reason}}
end
end

defp parse_quote_body(body, _symbol) 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
end
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.1.0"
@version "0.2.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. v0.1 ships single-symbol " <>
"quote fetch; batched quotes, FX, and dividend history planned."
"CSRF crumb auth flow transparently. Single + batched quote fetch " <>
"and FX rates; dividend history and search planned."
end

defp package do
Expand Down
Loading
Loading