diff --git a/.rubocop.yml b/.rubocop.yml index 57313a7..0f39c8e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,7 +11,7 @@ Metrics/AbcSize: Max: 25 Metrics/ClassLength: - Max: 310 + Max: 350 Metrics/MethodLength: Max: 11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9a319..9fec9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [0.8.0] - 2026-05-17 + +- Add `Stock.get_quote_summary(symbol)` returning `{ sector:, industry: }` via Yahoo's `v10/finance/quoteSummary?modules=assetProfile` endpoint. Returns `nil` for funds, ETFs, or any symbol where the asset profile is empty. Same cookie + crumb auth as the existing quote calls. + ## [0.7.0] - 2026-05-16 - Add `Stock.get_fx_rate(from, to)` returning the current FX rate between two ISO 4217 currency codes via Yahoo's `=X` quote symbol. Returns `1.0` for identity pairs without hitting the API; returns `nil` on error. diff --git a/Gemfile.lock b/Gemfile.lock index 2d563ad..1e4bce3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - yahoo_finance_client (0.7.0) + yahoo_finance_client (0.8.0) csv httparty (~> 0.21.0) diff --git a/README.md b/README.md index 0db013c..02d36eb 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ YahooFinanceClient::Stock.get_fx_rate("EUR", "USD") Identity pairs short-circuit to `1.0` without hitting the API. Returns `nil` on any upstream error. +### Sector / Industry + +Fetch the sector and industry for a ticker via Yahoo's `quoteSummary` endpoint: +```ruby +YahooFinanceClient::Stock.get_quote_summary("AAPL") +# => { sector: "Technology", industry: "Consumer Electronics" } +``` + +Returns `nil` for funds, ETFs, or any symbol whose `assetProfile` is empty. Sector data changes rarely — cache aggressively on the caller side. + ### Dividend History Fetch historical dividend payments via the chart API: diff --git a/lib/yahoo_finance_client/stock.rb b/lib/yahoo_finance_client/stock.rb index 56f8963..393614d 100644 --- a/lib/yahoo_finance_client/stock.rb +++ b/lib/yahoo_finance_client/stock.rb @@ -10,6 +10,7 @@ class Stock QUOTE_PATH = "/v7/finance/quote" CHART_PATH = "/v8/finance/chart" SEARCH_PATH = "/v1/finance/search" + QUOTE_SUMMARY_PATH = "/v10/finance/quoteSummary" SEARCH_BASE_URL = "https://query1.finance.yahoo.com" CACHE_TTL = 300 MAX_RETRIES = 2 @@ -50,6 +51,17 @@ def get_fx_rate(from, to) fetch_from_cache(cache_key) || fetch_and_cache_fx_rate(cache_key, from, to) end + # Fetch the sector and industry for a single ticker via Yahoo's + # quoteSummary endpoint (`assetProfile` module). Returns nil for funds, + # ETFs, or any symbol where Yahoo doesn't expose an asset profile. + # + # @param symbol [String] stock ticker + # @return [Hash{Symbol => String}, nil] { sector:, industry: } or nil + def get_quote_summary(symbol) + cache_key = "quote_summary_#{symbol}" + fetch_from_cache(cache_key) || fetch_and_cache_quote_summary(cache_key, symbol) + end + # Search the Yahoo Finance autocomplete index for matching symbols. # # @param query [String] free-text query (ticker or company name) @@ -235,6 +247,45 @@ def parse_fx_response(response) nil end + def fetch_and_cache_quote_summary(cache_key, symbol) + profile = fetch_quote_summary_data(symbol) + store_in_cache(cache_key, profile) if profile + profile + end + + def fetch_quote_summary_data(symbol) + retries = 0 + begin + parse_quote_summary_response(make_quote_summary_request(symbol)) + rescue AuthenticationError + retries += 1 + retry if retries <= MAX_RETRIES + nil + end + end + + def make_quote_summary_request(symbol) + session = Session.instance + session.ensure_authenticated + url = "#{session.base_url}#{QUOTE_SUMMARY_PATH}/#{symbol}?modules=assetProfile&crumb=#{session.crumb}" + HTTParty.get(url, headers: { "User-Agent" => Session::USER_AGENT, "Cookie" => session.cookie }) + end + + def parse_quote_summary_response(response) + if auth_error?(response) + Session.instance.invalidate! + raise AuthenticationError, "Authentication failed" + end + return nil unless response.success? + + profile = JSON.parse(response.body).dig("quoteSummary", "result", 0, "assetProfile") + return nil if profile.nil? || profile["sector"].to_s.empty? + + { sector: profile["sector"], industry: profile["industry"] } + rescue JSON::ParserError + nil + end + def fetch_and_cache_dividend_history(cache_key, symbol, range) data = fetch_dividend_history_data(symbol, range) store_in_cache(cache_key, data) unless data.empty? diff --git a/lib/yahoo_finance_client/version.rb b/lib/yahoo_finance_client/version.rb index 19ec2b9..0e6cc84 100644 --- a/lib/yahoo_finance_client/version.rb +++ b/lib/yahoo_finance_client/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module YahooFinanceClient - VERSION = "0.7.0" + VERSION = "0.8.0" end diff --git a/spec/yahoo_finance_client/stock_spec.rb b/spec/yahoo_finance_client/stock_spec.rb index f196120..6eaf65a 100644 --- a/spec/yahoo_finance_client/stock_spec.rb +++ b/spec/yahoo_finance_client/stock_spec.rb @@ -649,6 +649,104 @@ end end + describe ".get_quote_summary" do + let(:base_url) { "https://query1.finance.yahoo.com" } + let(:cookie_url) { "https://fc.yahoo.com" } + let(:crumb_url) { "https://query1.finance.yahoo.com/v1/test/getcrumb" } + let(:cookie) { "test_cookie" } + let(:crumb) { "test_crumb" } + let(:session) { YahooFinanceClient::Session.instance } + let(:profile_url) { "#{base_url}/v10/finance/quoteSummary/AAPL?modules=assetProfile&crumb=#{crumb}" } + + before do + described_class.instance_variable_set(:@cache, {}) + session.send(:reset!) + stub_request(:get, cookie_url).to_return(status: 200, headers: { "set-cookie" => cookie }) + stub_request(:get, crumb_url).with(headers: { "Cookie" => cookie }).to_return(status: 200, body: crumb) + end + + context "when Yahoo returns a valid asset profile" do + let(:response_body) do + { + "quoteSummary" => { + "result" => [ + { + "assetProfile" => { + "sector" => "Technology", + "industry" => "Consumer Electronics" + } + } + ] + } + }.to_json + end + + before { stub_request(:get, profile_url).to_return(status: 200, body: response_body) } + + it "returns the sector and industry" do + expect(described_class.get_quote_summary("AAPL")).to eq(sector: "Technology", industry: "Consumer Electronics") + end + + it "caches the response" do + described_class.get_quote_summary("AAPL") + described_class.get_quote_summary("AAPL") + expect(WebMock).to have_requested(:get, profile_url).once + end + end + + context "when Yahoo returns no asset profile (ETF / fund)" do + let(:response_body) { { "quoteSummary" => { "result" => [{}] } }.to_json } + + before { stub_request(:get, profile_url).to_return(status: 200, body: response_body) } + + it "returns nil" do + expect(described_class.get_quote_summary("AAPL")).to be_nil + end + + it "does not cache the nil result" do + described_class.get_quote_summary("AAPL") + cache = described_class.instance_variable_get(:@cache) + expect(cache).not_to have_key("quote_summary_AAPL") + end + end + + context "when assetProfile has empty sector" do + let(:response_body) do + { "quoteSummary" => { "result" => [{ "assetProfile" => { "sector" => "", "industry" => "" } }] } }.to_json + end + + before { stub_request(:get, profile_url).to_return(status: 200, body: response_body) } + + it "returns nil" do + expect(described_class.get_quote_summary("AAPL")).to be_nil + end + end + + context "when Yahoo returns a non-success response" do + before { stub_request(:get, profile_url).to_return(status: 500, body: "boom") } + + it "returns nil" do + expect(described_class.get_quote_summary("AAPL")).to be_nil + end + end + + context "when the response body is not valid JSON" do + before { stub_request(:get, profile_url).to_return(status: 200, body: "nope") } + + it "returns nil" do + expect(described_class.get_quote_summary("AAPL")).to be_nil + end + end + + context "when authentication fails after max retries" do + before { stub_request(:get, profile_url).to_return(status: 401, body: "unauthorized") } + + it "returns nil" do + expect(described_class.get_quote_summary("AAPL")).to be_nil + end + end + end + describe ".get_quotes" do let(:base_url) { "https://query1.finance.yahoo.com" } let(:cookie_url) { "https://fc.yahoo.com" }