From da5ed90651a1aa31defed2cb3e39c88e1c5ca926 Mon Sep 17 00:00:00 2001 From: Francesc Leveque Date: Mon, 8 Jun 2026 18:49:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v0.2=20=E2=80=94=20batched=20quotes=20(?= =?UTF-8?q?get=5Fquotes/1)=20and=20FX=20(get=5Ffx=5Frate/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new public functions, both reusing the existing Session + HTTP wrapper so the cookie + crumb auth flow is unchanged. YahooFinanceEx.get_quotes/1 - Takes a list of symbols; returns {:ok, %{symbol => result}} where result is {:ok, Quote.t()} | {:error, :not_found}. - Internally chunks into batches of 50 (Yahoo's per-request ceiling) and merges results across batches. - Dedupes the input list before requesting. - Top-level errors (auth, transport, http_status) abort the whole call and bubble up; partial misses surface inside the result map. YahooFinanceEx.get_fx_rate/2 - Uses Yahoo's =X ticker convention internally. - Identity pairs (USD/USD) short-circuit to {:ok, 1.0} without hitting the API. - Returns {:ok, rate} as a float on success. Version bump 0.1.0 → 0.2.0; README + CHANGELOG updated to match; package description tightened to reflect actual surface. 7 new tests on top of the 3 existing — 10 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 17 ++++ README.md | 33 ++++-- lib/yahoo_finance_ex.ex | 143 ++++++++++++++++++++++---- mix.exs | 6 +- test/yahoo_finance_ex_test.exs | 179 ++++++++++++++++++++++++++------- 5 files changed, 310 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5660ee..28343dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `=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 diff --git a/README.md b/README.md index 2852d86..6e02856 100644 --- a/README.md +++ b/README.md @@ -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 `=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 @@ -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 ``` @@ -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 ``` diff --git a/lib/yahoo_finance_ex.ex b/lib/yahoo_finance_ex.ex index 8c77516..51c1f54 100644 --- a/lib/yahoo_finance_ex.ex +++ b/lib/yahoo_finance_ex.ex @@ -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 `=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 @@ -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 :: @@ -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. @@ -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() @@ -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} @@ -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 diff --git a/mix.exs b/mix.exs index cdee3aa..3017feb 100644 --- a/mix.exs +++ b/mix.exs @@ -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 @@ -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 diff --git a/test/yahoo_finance_ex_test.exs b/test/yahoo_finance_ex_test.exs index a56eff3..4b308f4 100644 --- a/test/yahoo_finance_ex_test.exs +++ b/test/yahoo_finance_ex_test.exs @@ -15,18 +15,13 @@ defmodule YahooFinanceExTest do stub_yahoo(fn conn -> case {conn.host, conn.request_path} do {"fc.yahoo.com", _} -> - conn - |> Plug.Conn.put_resp_header( - "set-cookie", - "A1=fake-cookie; Path=/; Domain=.yahoo.com" - ) - |> Plug.Conn.send_resp(200, "") + cookie(conn) {"query1.finance.yahoo.com", "/v1/test/getcrumb"} -> Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") {"query1.finance.yahoo.com", "/v7/finance/quote"} -> - Req.Test.json(conn, quote_payload("AAPL", 187.42)) + Req.Test.json(conn, quote_payload([{"AAPL", 187.42}])) end end) @@ -38,9 +33,7 @@ defmodule YahooFinanceExTest do stub_yahoo(fn conn -> case {conn.host, conn.request_path} do {"fc.yahoo.com", _} -> - conn - |> Plug.Conn.put_resp_header("set-cookie", "A1=fake-cookie") - |> Plug.Conn.send_resp(200, "") + cookie(conn) {"query1.finance.yahoo.com", "/v1/test/getcrumb"} -> Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") @@ -57,9 +50,7 @@ defmodule YahooFinanceExTest do stub_yahoo(fn conn -> case {conn.host, conn.request_path} do {"fc.yahoo.com", _} -> - conn - |> Plug.Conn.put_resp_header("set-cookie", "A1=fake-cookie") - |> Plug.Conn.send_resp(200, "") + cookie(conn) {_, "/v1/test/getcrumb"} -> Plug.Conn.send_resp(conn, 200, "fake-crumb-abc") @@ -73,6 +64,119 @@ defmodule YahooFinanceExTest do end end + describe "get_quotes/1" do + test "returns an empty map for an empty list (no HTTP call)" do + assert {:ok, %{}} = YahooFinanceEx.get_quotes([]) + end + + test "returns each symbol's parsed quote keyed in the response map" 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", "/v7/finance/quote"} -> + Req.Test.json( + conn, + quote_payload([{"AAPL", 187.42}, {"MSFT", 400.0}]) + ) + end + end) + + assert {:ok, results} = YahooFinanceEx.get_quotes(["AAPL", "MSFT"]) + assert {:ok, %Quote{symbol: "AAPL", price: 187.42}} = Map.fetch!(results, "AAPL") + assert {:ok, %Quote{symbol: "MSFT", price: 400.0}} = Map.fetch!(results, "MSFT") + end + + test "marks symbols Yahoo doesn't return as {:error, :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", "/v7/finance/quote"} -> + Req.Test.json(conn, quote_payload([{"AAPL", 187.42}])) + end + end) + + assert {:ok, results} = YahooFinanceEx.get_quotes(["AAPL", "FAKE"]) + assert {:ok, %Quote{symbol: "AAPL"}} = Map.fetch!(results, "AAPL") + assert {:error, :not_found} = Map.fetch!(results, "FAKE") + end + + test "dedupes the input list before requesting" 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", "/v7/finance/quote"} -> + symbols = conn.params["symbols"] || conn.query_params["symbols"] + send(self(), {:symbols, symbols}) + Req.Test.json(conn, quote_payload([{"AAPL", 187.42}])) + end + end) + + assert {:ok, %{"AAPL" => {:ok, _}}} = YahooFinanceEx.get_quotes(["AAPL", "AAPL"]) + assert_received {:symbols, "AAPL"} + end + end + + describe "get_fx_rate/2" do + test "short-circuits identity pairs to 1.0 without hitting the API" do + # No stub registered — any HTTP would crash. Identity must skip it. + assert {:ok, 1.0} = YahooFinanceEx.get_fx_rate("USD", "USD") + end + + test "builds a Yahoo =X symbol and returns the price as the rate" 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", "/v7/finance/quote"} -> + symbols = conn.params["symbols"] || conn.query_params["symbols"] + send(self(), {:symbols, symbols}) + Req.Test.json(conn, quote_payload([{"EURUSD=X", 1.08}])) + end + end) + + assert {:ok, 1.08} = YahooFinanceEx.get_fx_rate("EUR", "USD") + assert_received {:symbols, "EURUSD=X"} + end + + test "propagates :not_found when Yahoo has no quote for the pair" 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", "/v7/finance/quote"} -> + Req.Test.json(conn, %{"quoteResponse" => %{"result" => []}}) + end + end) + + assert {:error, :not_found} = YahooFinanceEx.get_fx_rate("XYZ", "ABC") + end + end + + ## Helpers + defp stub_yahoo(fun) do Req.Test.stub(YahooFinanceEx.HTTPStub, fun) # The Session GenServer (started by the package's Application) lives @@ -81,29 +185,36 @@ defmodule YahooFinanceExTest do :ok end - defp quote_payload(symbol, price) do + defp cookie(conn) do + conn + |> Plug.Conn.put_resp_header("set-cookie", "A1=fake-cookie; Path=/; Domain=.yahoo.com") + |> Plug.Conn.send_resp(200, "") + end + + defp quote_payload(symbol_prices) when is_list(symbol_prices) do %{ "quoteResponse" => %{ - "result" => [ - %{ - "symbol" => symbol, - "shortName" => "Apple Inc.", - "regularMarketPrice" => price, - "currency" => "USD", - "regularMarketChange" => 1.23, - "regularMarketChangePercent" => 0.66, - "regularMarketVolume" => 12_345_678, - "trailingPE" => 30.5, - "epsTrailingTwelveMonths" => 6.15, - "dividendRate" => 0.96, - "fiftyDayAverage" => 180.0, - "twoHundredDayAverage" => 175.0, - "fiftyTwoWeekHigh" => 199.0, - "fiftyTwoWeekLow" => 150.0, - "exDividendDate" => 1_707_955_200, - "dividendDate" => 1_708_473_600 - } - ] + "result" => + Enum.map(symbol_prices, fn {symbol, price} -> + %{ + "symbol" => symbol, + "shortName" => "#{symbol} Inc.", + "regularMarketPrice" => price, + "currency" => "USD", + "regularMarketChange" => 1.0, + "regularMarketChangePercent" => 0.5, + "regularMarketVolume" => 1_000_000, + "trailingPE" => 25.0, + "epsTrailingTwelveMonths" => 5.0, + "dividendRate" => 1.0, + "fiftyDayAverage" => price, + "twoHundredDayAverage" => price, + "fiftyTwoWeekHigh" => price * 1.2, + "fiftyTwoWeekLow" => price * 0.8, + "exDividendDate" => 1_707_955_200, + "dividendDate" => 1_708_473_600 + } + end) } } end