diff --git a/.rubocop.yml b/.rubocop.yml index 0fbc322..889fb6e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,7 +11,7 @@ Metrics/AbcSize: Max: 25 Metrics/ClassLength: - Max: 150 + Max: 200 Metrics/MethodLength: Max: 11 diff --git a/Gemfile.lock b/Gemfile.lock index 20c4890..59c8352 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - yahoo_finance_client (0.3.1) + yahoo_finance_client (0.4.0) csv httparty (~> 0.21.0) diff --git a/lib/yahoo_finance_client/stock.rb b/lib/yahoo_finance_client/stock.rb index 1ccd5f4..8abe0b5 100644 --- a/lib/yahoo_finance_client/stock.rb +++ b/lib/yahoo_finance_client/stock.rb @@ -7,6 +7,7 @@ module YahooFinanceClient # This class provides methods to interact with Yahoo Finance API for stock data. class Stock QUOTE_PATH = "/v7/finance/quote" + CHART_PATH = "/v8/finance/chart" CACHE_TTL = 300 MAX_RETRIES = 2 BATCH_SIZE = 50 @@ -28,6 +29,11 @@ def get_quotes(symbols) results end + def get_dividend_history(symbol, range: "2y") + cache_key = "div_history_#{symbol}_#{range}" + fetch_from_cache(cache_key) || fetch_and_cache_dividend_history(cache_key, symbol, range) + end + private def partition_cached(symbols) @@ -169,6 +175,61 @@ def parse_unix_date(value) Time.at(value).utc.to_date 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? + data + end + + def fetch_dividend_history_data(symbol, range) + retries = 0 + begin + response = make_chart_request(symbol, range) + parse_dividend_history(response) + rescue AuthenticationError + retries += 1 + retry if retries <= MAX_RETRIES + [] + end + end + + def make_chart_request(symbol, range) + session = Session.instance + session.ensure_authenticated + url = "#{session.base_url}#{CHART_PATH}/#{symbol}?range=#{range}&interval=1mo&events=div&crumb=#{session.crumb}" + HTTParty.get(url, headers: { "User-Agent" => Session::USER_AGENT, "Cookie" => session.cookie }) + end + + def parse_dividend_history(response) + raise_if_auth_error(response) + return [] unless response.success? + + dividends = JSON.parse(response.body).dig("chart", "result", 0, "events", "dividends") + return [] unless dividends + + build_dividend_entries(dividends) + end + + def raise_if_auth_error(response) + return unless auth_error?(response) + + Session.instance.invalidate! + raise AuthenticationError, "Authentication failed" + end + + def build_dividend_entries(dividends) + entries = dividends.values.filter_map { |entry| parse_dividend_entry(entry) } + entries.sort_by { |d| d[:date] } + end + + def parse_dividend_entry(entry) + date = parse_unix_date(entry["date"]) + amount = entry["amount"] + return unless date && amount&.positive? + + { date: date, amount: amount.round(4) } + end + def fetch_from_cache(key) cached_entry = @cache[key] return unless cached_entry && Time.now - cached_entry[:timestamp] < CACHE_TTL diff --git a/lib/yahoo_finance_client/version.rb b/lib/yahoo_finance_client/version.rb index 9d48879..a4259c5 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.3.1" + VERSION = "0.4.0" end diff --git a/spec/yahoo_finance_client/stock_spec.rb b/spec/yahoo_finance_client/stock_spec.rb index d2469ad..fba49ff 100644 --- a/spec/yahoo_finance_client/stock_spec.rb +++ b/spec/yahoo_finance_client/stock_spec.rb @@ -742,4 +742,149 @@ end end end + + describe ".get_dividend_history" do + let(:symbol) { "AAPL" } + let(:base_url) { "https://query1.finance.yahoo.com" } + let(:chart_url) { "#{base_url}/v8/finance/chart/#{symbol}?range=2y&interval=1mo&events=div&crumb=#{crumb}" } + 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 } + + 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 dividends exist" do + let(:response_body) do + { + "chart" => { + "result" => [ + { + "events" => { + "dividends" => { + "1707955200" => { "date" => 1_707_955_200, "amount" => 0.24 }, + "1715644800" => { "date" => 1_715_644_800, "amount" => 0.25 }, + "1723420800" => { "date" => 1_723_420_800, "amount" => 0.25 }, + "1731196800" => { "date" => 1_731_196_800, "amount" => 0.25 } + } + } + } + ] + } + }.to_json + end + + before do + stub_request(:get, chart_url) + .to_return(status: 200, body: response_body) + end + + it "returns sorted array of dividend events" do + result = described_class.get_dividend_history(symbol) + expect(result).to be_an(Array) + expect(result.size).to eq(4) + expect(result.first[:date]).to be_a(Date) + expect(result.first[:amount]).to eq(0.24) + expect(result.last[:amount]).to eq(0.25) + end + + it "returns dates sorted chronologically" do + result = described_class.get_dividend_history(symbol) + dates = result.map { |d| d[:date] } + expect(dates).to eq(dates.sort) + end + + it "caches the result" do + described_class.get_dividend_history(symbol) + cache = described_class.instance_variable_get(:@cache) + expect(cache["div_history_#{symbol}_2y"]).not_to be_nil + end + end + + context "when no dividends exist" do + let(:response_body) do + { + "chart" => { + "result" => [ + { + "events" => {} + } + ] + } + }.to_json + end + + before do + stub_request(:get, chart_url) + .to_return(status: 200, body: response_body) + end + + it "returns empty array" do + result = described_class.get_dividend_history(symbol) + expect(result).to eq([]) + end + end + + context "when API returns error" do + before do + stub_request(:get, chart_url) + .to_return(status: 500, body: "") + end + + it "returns empty array" do + result = described_class.get_dividend_history(symbol) + expect(result).to eq([]) + end + end + + context "when authentication fails after max retries" do + before do + stub_request(:get, chart_url) + .to_return(status: 401, body: "Unauthorized") + end + + it "returns empty array" do + result = described_class.get_dividend_history(symbol) + expect(result).to eq([]) + end + end + + context "with custom range parameter" do + let(:chart_url_1y) { "#{base_url}/v8/finance/chart/#{symbol}?range=1y&interval=1mo&events=div&crumb=#{crumb}" } + let(:response_body) do + { + "chart" => { + "result" => [ + { + "events" => { + "dividends" => { + "1715644800" => { "date" => 1_715_644_800, "amount" => 0.25 } + } + } + } + ] + } + }.to_json + end + + before do + stub_request(:get, chart_url_1y) + .to_return(status: 200, body: response_body) + end + + it "uses the specified range" do + result = described_class.get_dividend_history(symbol, range: "1y") + expect(result.size).to eq(1) + end + end + end end