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: 150
Max: 200

Metrics/MethodLength:
Max: 11
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.3.1)
yahoo_finance_client (0.4.0)
csv
httparty (~> 0.21.0)

Expand Down
61 changes: 61 additions & 0 deletions lib/yahoo_finance_client/stock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
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.3.1"
VERSION = "0.4.0"
end
145 changes: 145 additions & 0 deletions spec/yahoo_finance_client/stock_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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