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
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Metrics/AbcSize:
Max: 25

Metrics/ClassLength:
Max: 310
Max: 350

Metrics/MethodLength:
Max: 11
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<FROM><TO>=X` quote symbol. Returns `1.0` for identity pairs without hitting the API; returns `nil` on error.
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
yahoo_finance_client (0.7.0)
yahoo_finance_client (0.8.0)
csv
httparty (~> 0.21.0)

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions lib/yahoo_finance_client/stock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion lib/yahoo_finance_client/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module YahooFinanceClient
VERSION = "0.7.0"
VERSION = "0.8.0"
end
98 changes: 98 additions & 0 deletions spec/yahoo_finance_client/stock_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<html>nope</html>") }

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" }
Expand Down
Loading