yfinrb 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +13 -0
- data/CHANGELOG.rst +0 -0
- data/CODE_OF_CONDUCT.md +15 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +8 -0
- data/lib/yfinrb/analysis.rb +68 -0
- data/lib/yfinrb/financials.rb +302 -0
- data/lib/yfinrb/fundamentals.rb +54 -0
- data/lib/yfinrb/holders.rb +260 -0
- data/lib/yfinrb/multi.rb +238 -0
- data/lib/yfinrb/price_history.rb +2037 -0
- data/lib/yfinrb/quote.rb +342 -0
- data/lib/yfinrb/ticker.rb +381 -0
- data/lib/yfinrb/tickers.rb +52 -0
- data/lib/yfinrb/utils.rb +359 -0
- data/lib/yfinrb/version.rb +5 -0
- data/lib/yfinrb/yf_connection.rb +300 -0
- data/lib/yfinrb/yfinance_exception.rb +16 -0
- data/lib/yfinrb.rb +17 -0
- data/sig/yfinrb.rbs +4 -0
- metadata +124 -0
@@ -0,0 +1,381 @@
|
|
1
|
+
|
2
|
+
class Yfin
|
3
|
+
class Ticker
|
4
|
+
ROOT_URL = 'https://finance.yahoo.com'.freeze
|
5
|
+
BASE_URL = 'https://query2.finance.yahoo.com'.freeze
|
6
|
+
|
7
|
+
include YfConnection
|
8
|
+
|
9
|
+
attr_accessor :tz, :proxy, :isin, :timeout
|
10
|
+
attr_reader :error_message, :ticker
|
11
|
+
|
12
|
+
class YahooFinanceException < Exception
|
13
|
+
end
|
14
|
+
|
15
|
+
class SymbolNotFoundException < YahooFinanceException
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(ticker)
|
19
|
+
@proxy = nil
|
20
|
+
@timeout = 30
|
21
|
+
@tz = TZInfo::Timezone.get('America/New_York')
|
22
|
+
|
23
|
+
@isin = nil
|
24
|
+
@news = []
|
25
|
+
@shares = nil
|
26
|
+
|
27
|
+
@earnings_dates = {}
|
28
|
+
@expirations = {}
|
29
|
+
@underlying = {}
|
30
|
+
|
31
|
+
@ticker = (Utils.is_isin(ticker.upcase) ? Utils.get_ticker_by_isin(ticker.upcase, nil, @session) : ticker).upcase
|
32
|
+
|
33
|
+
yfconn_initialize
|
34
|
+
end
|
35
|
+
|
36
|
+
include PriceHistory
|
37
|
+
include Analysis
|
38
|
+
include Fundamentals
|
39
|
+
include Holders
|
40
|
+
include Quote
|
41
|
+
include Financials
|
42
|
+
|
43
|
+
alias_method :symbol, :ticker
|
44
|
+
|
45
|
+
def symbol; @ticker; end
|
46
|
+
|
47
|
+
def shares_full(start: nil, fin: nil)
|
48
|
+
logger = Rails.logger
|
49
|
+
|
50
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
|
51
|
+
|
52
|
+
if start
|
53
|
+
start_ts = Utils.parse_user_dt(start, tz)
|
54
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} start_ts = #{start_ts}" }
|
55
|
+
start = Time.at(start_ts).in_time_zone(tz)
|
56
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
|
57
|
+
end
|
58
|
+
if fin
|
59
|
+
end_ts = Utils.parse_user_dt(fin, tz)
|
60
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} end_ts = #{end_ts}" }
|
61
|
+
fin = Time.at(end_ts).in_time_zone(tz)
|
62
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
|
63
|
+
end
|
64
|
+
|
65
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
|
66
|
+
|
67
|
+
dt_now = DateTime.now.in_time_zone(tz)
|
68
|
+
fin ||= dt_now
|
69
|
+
start ||= (fin - 548.days).midnight
|
70
|
+
|
71
|
+
if start >= fin
|
72
|
+
logger.error("Start date (#{start}) must be before end (#{fin})")
|
73
|
+
return nil
|
74
|
+
end
|
75
|
+
|
76
|
+
ts_url_base = "https://query2.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/#{@ticker}?symbol=#{@ticker}"
|
77
|
+
shares_url = "#{ts_url_base}&period1=#{start.to_i}&period2=#{fin.tomorrow.midnight.to_i}"
|
78
|
+
|
79
|
+
begin
|
80
|
+
json_data = get(shares_url).parsed_response
|
81
|
+
rescue #_json.JSONDecodeError, requests.exceptions.RequestException
|
82
|
+
logger.error("#{@ticker}: Yahoo web request for share count failed")
|
83
|
+
return nil
|
84
|
+
end
|
85
|
+
|
86
|
+
fail = json_data["finance"]["error"]["code"] == "Bad Request" rescue false
|
87
|
+
if fail
|
88
|
+
logger.error("#{@ticker}: Yahoo web request for share count failed")
|
89
|
+
return nil
|
90
|
+
end
|
91
|
+
|
92
|
+
shares_data = json_data["timeseries"]["result"]
|
93
|
+
|
94
|
+
return nil if !shares_data[0].key?("shares_out")
|
95
|
+
|
96
|
+
timestamps = shares_data[0]["timestamp"].map{|t| Time.at(t).to_datetime }
|
97
|
+
|
98
|
+
df = Polars::DataFrame.new(
|
99
|
+
{
|
100
|
+
'Timestamps': timestamps,
|
101
|
+
"Shares": shares_data[0]["shares_out"]
|
102
|
+
}
|
103
|
+
)
|
104
|
+
|
105
|
+
return df
|
106
|
+
end
|
107
|
+
|
108
|
+
def shares
|
109
|
+
return @shares unless @shares.nil?
|
110
|
+
|
111
|
+
full_shares = shares_full(start: DateTime.now.utc.to_date-548.days, fin: DateTime.now.utc.to_date)
|
112
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} full_shares = #{full_shares.inspect}" }
|
113
|
+
|
114
|
+
# if shares.nil?
|
115
|
+
# # Requesting 18 months failed, so fallback to shares which should include last year
|
116
|
+
# shares = @ticker.get_shares()
|
117
|
+
|
118
|
+
# if shares.nil?
|
119
|
+
full_shares = full_shares['Shares'] if full_shares.is_a?(Polars::DataFrame)
|
120
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} full_shares = #{full_shares.inspect}" }
|
121
|
+
@shares = full_shares[-1].to_i
|
122
|
+
# end
|
123
|
+
# return @shares
|
124
|
+
end
|
125
|
+
|
126
|
+
def news()
|
127
|
+
return @news unless @news.empty?
|
128
|
+
|
129
|
+
url = "#{BASE_URL}/v1/finance/search?q=#{@ticker}"
|
130
|
+
data = get(url).parsed_response
|
131
|
+
if data.include?("Will be right back")
|
132
|
+
raise RuntimeError("*** YAHOO! FINANCE IS CURRENTLY DOWN! ***\nOur engineers are working quickly to resolve the issue. Thank you for your patience.")
|
133
|
+
end
|
134
|
+
|
135
|
+
@news = {}
|
136
|
+
data['news'].each do |item|
|
137
|
+
@news[item['title']] = item['link']
|
138
|
+
end
|
139
|
+
|
140
|
+
return @news
|
141
|
+
end
|
142
|
+
|
143
|
+
def earnings_dates(limit = 12)
|
144
|
+
# """
|
145
|
+
# Get earning dates (future and historic)
|
146
|
+
# :param limit: max amount of upcoming and recent earnings dates to return.
|
147
|
+
# Default value 12 should return next 4 quarters and last 8 quarters.
|
148
|
+
# Increase if more history is needed.
|
149
|
+
|
150
|
+
# :return: Polars dataframe
|
151
|
+
# """
|
152
|
+
return @earnings_dates[limit] if @earnings_dates && @earnings_dates[limit]
|
153
|
+
|
154
|
+
logger = Rails.logger
|
155
|
+
|
156
|
+
page_size = [limit, 100].min # YF caps at 100, don't go higher
|
157
|
+
page_offset = 0
|
158
|
+
dates = nil
|
159
|
+
# while true
|
160
|
+
url = "#{ROOT_URL}/calendar/earnings?symbol=#{@ticker}&offset=#{page_offset}&size=#{page_size}"
|
161
|
+
data = get(url).parsed_response # @data.cache_get(url: url).text
|
162
|
+
|
163
|
+
if data.include?("Will be right back")
|
164
|
+
raise RuntimeError, "*** YAHOO! FINANCE IS CURRENTLY DOWN! ***\nOur engineers are working quickly to resolve the issue. Thank you for your patience."
|
165
|
+
end
|
166
|
+
|
167
|
+
csv = ''
|
168
|
+
doc = Nokogiri::HTML(data)
|
169
|
+
tbl = doc.xpath("//table").first
|
170
|
+
tbl.search('tr').each do |tr|
|
171
|
+
cells = tr.search('th, td')
|
172
|
+
csv += CSV.generate_line(cells)
|
173
|
+
end
|
174
|
+
csv = CSV.parse(csv)
|
175
|
+
|
176
|
+
df = {}
|
177
|
+
(0..csv[0].length-1).each{|i| df[csv[0][i]] = csv[1..-1].transpose[i] }
|
178
|
+
dates = Polars::DataFrame.new(df)
|
179
|
+
# end
|
180
|
+
|
181
|
+
# Drop redundant columns
|
182
|
+
dates = dates.drop(["Symbol", "Company"]) #, axis: 1)
|
183
|
+
|
184
|
+
# Convert types
|
185
|
+
["EPS Estimate", "Reported EPS", "Surprise(%)"].each do |cn|
|
186
|
+
s = Polars::Series.new([Float::NAN] * (dates.shape.first))
|
187
|
+
(0..(dates.shape.first-1)).to_a.each {|i| s[i] = dates[cn][i].to_f unless dates[cn][i] == '-' }
|
188
|
+
dates.replace(cn, s)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Convert % to range 0->1:
|
192
|
+
dates["Surprise(%)"] *= 0.01
|
193
|
+
|
194
|
+
# Parse earnings date string
|
195
|
+
s = Polars::Series.new(dates['Earnings Date'].map{|t| Time.at(t.to_datetime.to_i).to_datetime }, dtype: :i64)
|
196
|
+
dates.replace('Earnings Date', s)
|
197
|
+
|
198
|
+
|
199
|
+
@earnings_dates[limit] = dates
|
200
|
+
|
201
|
+
dates
|
202
|
+
end
|
203
|
+
|
204
|
+
def option_chain(date = nil, tz = nil)
|
205
|
+
options = if date.nil?
|
206
|
+
download_options
|
207
|
+
else
|
208
|
+
download_options if @expirations.empty? || date.nil?
|
209
|
+
raise "Expiration `#{date}` cannot be found. Available expirations are: [#{@expirations.keys.join(', ')}]" unless @expirations.key?(date)
|
210
|
+
|
211
|
+
download_options(@expirations[date])
|
212
|
+
end
|
213
|
+
|
214
|
+
df = OpenStruct.new(
|
215
|
+
calls: _options_to_df(options['calls'], tz),
|
216
|
+
puts: _options_to_df(options['puts'], tz),
|
217
|
+
underlying: options['underlying']
|
218
|
+
)
|
219
|
+
end
|
220
|
+
|
221
|
+
def options
|
222
|
+
download_options if @expirations.empty?
|
223
|
+
@expirations.keys
|
224
|
+
end
|
225
|
+
|
226
|
+
alias_method :option_expiration_dates, :options
|
227
|
+
|
228
|
+
def is_valid_timezone(tz)
|
229
|
+
begin
|
230
|
+
_tz.timezone(tz)
|
231
|
+
rescue UnknownTimeZoneError
|
232
|
+
return false
|
233
|
+
end
|
234
|
+
return true
|
235
|
+
end
|
236
|
+
|
237
|
+
def to_s
|
238
|
+
"yfinance.Ticker object <#{ticker}>"
|
239
|
+
end
|
240
|
+
|
241
|
+
def download_options(date = nil)
|
242
|
+
url = date.nil? ? "#{BASE_URL}/v7/finance/options/#{@ticker}" : "#{BASE_URL}/v7/finance/options/#{@ticker}?date=#{date}"
|
243
|
+
|
244
|
+
response = get(url).parsed_response #Net::HTTP.get(uri)
|
245
|
+
|
246
|
+
if response['optionChain'].key?('result') #r.dig('optionChain', 'result')&.any?
|
247
|
+
response['optionChain']['result'][0]['expirationDates'].each do |exp|
|
248
|
+
@expirations[Time.at(exp).utc.strftime('%Y-%m-%d')] = exp
|
249
|
+
end
|
250
|
+
|
251
|
+
@underlying = response['optionChain']['result'][0]['quote'] || {}
|
252
|
+
|
253
|
+
opt = response['optionChain']['result'][0]['options'] || []
|
254
|
+
|
255
|
+
return opt.empty? ? {} : opt[0].merge('underlying' => @underlying)
|
256
|
+
end
|
257
|
+
{}
|
258
|
+
end
|
259
|
+
|
260
|
+
def isin()
|
261
|
+
return @isin if !@isin.nil?
|
262
|
+
|
263
|
+
# ticker = @ticker.upcase
|
264
|
+
|
265
|
+
if ticker.include?("-") || ticker.include?("^")
|
266
|
+
@isin = '-'
|
267
|
+
return @isin
|
268
|
+
end
|
269
|
+
|
270
|
+
q = ticker
|
271
|
+
@info ||= info
|
272
|
+
return nil if @info.nil?
|
273
|
+
|
274
|
+
# q = @info['quoteType'].try(:[],'shortName') # if @info.key?("shortName")
|
275
|
+
|
276
|
+
url = "https://markets.businessinsider.com/ajax/SearchController_Suggest?max_results=25&query=#{(q)}"
|
277
|
+
data = get(url).parsed_response
|
278
|
+
|
279
|
+
search_str = "\"#{ticker}|"
|
280
|
+
if !data.include?(search_str)
|
281
|
+
if data.downcase.include?(q.downcase)
|
282
|
+
search_str = '"|'
|
283
|
+
if !data.include?(search_str)
|
284
|
+
@isin = '-'
|
285
|
+
return @isin
|
286
|
+
end
|
287
|
+
else
|
288
|
+
@isin = '-'
|
289
|
+
return @isin
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
@isin = data.split(search_str)[1].split('"')[0].split('|')[0]
|
294
|
+
return @isin
|
295
|
+
end
|
296
|
+
|
297
|
+
|
298
|
+
|
299
|
+
|
300
|
+
|
301
|
+
|
302
|
+
|
303
|
+
|
304
|
+
|
305
|
+
|
306
|
+
|
307
|
+
|
308
|
+
private
|
309
|
+
|
310
|
+
# def _lazy_load_price_history
|
311
|
+
# @price_history ||= PriceHistory.new(@ticker, _get_ticker_tz(@proxy, timeout: 10), @data)
|
312
|
+
# end
|
313
|
+
|
314
|
+
alias_method :_get_ticker_tz, :tz
|
315
|
+
# def _get_ticker_tz(proxy=nil, timeout=nil)
|
316
|
+
# return @tz
|
317
|
+
# end
|
318
|
+
|
319
|
+
# def _fetch_ticker_tz(proxy, timeout)
|
320
|
+
# proxy ||= @proxy
|
321
|
+
|
322
|
+
# params = {"range": "1d", "interval": "1d"}
|
323
|
+
|
324
|
+
# url = "#{BASE_URL}/v8/finance/chart/#{@ticker}"
|
325
|
+
|
326
|
+
# begin
|
327
|
+
# data = @data.cache_get(url: url, params: params, proxy: proxy, timeout: timeout)
|
328
|
+
# data = data.json()
|
329
|
+
# rescue Exception => e
|
330
|
+
# Rails.logger.error("Failed to get ticker '#{@ticker}' reason: #{e}")
|
331
|
+
# return nil
|
332
|
+
# end
|
333
|
+
|
334
|
+
# error = data.get('chart', {}).get('error', nil)
|
335
|
+
# if error
|
336
|
+
# Rails.logger.debug("Got error from yahoo api for ticker #{@ticker}, Error: #{error}")
|
337
|
+
# else
|
338
|
+
# begin
|
339
|
+
# return data["chart"]["result"][0]["meta"]["exchangeTimezoneName"]
|
340
|
+
# rescue Exception => err
|
341
|
+
# Rails.logger.error("Could not get exchangeTimezoneName for ticker '#{@ticker}' reason: #{err}")
|
342
|
+
# Rails.logger.debug("Got response: ")
|
343
|
+
# Rails.logger.debug("-------------")
|
344
|
+
# Rails.logger.debug(" #{data}")
|
345
|
+
# Rails.logger.debug("-------------")
|
346
|
+
# end
|
347
|
+
# end
|
348
|
+
|
349
|
+
# return nil
|
350
|
+
# end
|
351
|
+
|
352
|
+
def _options_to_df(opt, tz = nil)
|
353
|
+
data = opt.map do |o|
|
354
|
+
{
|
355
|
+
contractSymbol: o['contractSymbol'],
|
356
|
+
lastTradeDate: DateTime.strptime(o['lastTradeDate'].to_s, '%s').new_offset(0),
|
357
|
+
strike: o['strike'],
|
358
|
+
lastPrice: o['lastPrice'],
|
359
|
+
bid: o['bid'],
|
360
|
+
ask: o['ask'],
|
361
|
+
change: o['change'],
|
362
|
+
percentChange: o['percentChange'],
|
363
|
+
volume: o['volume'],
|
364
|
+
openInterest: o['openInterest'],
|
365
|
+
impliedVolatility: o['impliedVolatility'],
|
366
|
+
inTheMoney: o['inTheMoney'],
|
367
|
+
contractSize: o['contractSize'],
|
368
|
+
currency: o['currency']
|
369
|
+
}
|
370
|
+
end
|
371
|
+
|
372
|
+
if tz
|
373
|
+
data.each do |d|
|
374
|
+
d[:lastTradeDate] = d[:lastTradeDate].new_offset(tz)
|
375
|
+
end
|
376
|
+
end
|
377
|
+
|
378
|
+
data
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
class Yfin
|
4
|
+
class Tickers
|
5
|
+
def initialize(tickers, session = nil)
|
6
|
+
tickers = tickers.is_a?(Array) ? tickers : tickers.split(',')
|
7
|
+
@symbols = tickers.map(&:upcase)
|
8
|
+
@tickers = @symbols.each_with_object({}) do |ticker, hash|
|
9
|
+
hash[ticker] = Ticker.new(ticker, session: session)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
"yfinance.Tickers object <#{@symbols.join(', ')}>"
|
15
|
+
end
|
16
|
+
|
17
|
+
def history(period: "1mo", interval: "1d", start: nil, fin: nil, prepost: false,
|
18
|
+
actions: true, auto_adjust: true, repair: false, proxy: nil,
|
19
|
+
threads: true, group_by: 'column', progress: true, timeout: 10, **kwargs)
|
20
|
+
download(period: period, interval: interval, start: start, fin: fin, prepost: prepost,
|
21
|
+
actions: actions, auto_adjust: auto_adjust, repair: repair, proxy: proxy,
|
22
|
+
threads: threads, group_by: group_by, progress: progress, timeout: timeout, **kwargs)
|
23
|
+
end
|
24
|
+
|
25
|
+
def download(period: "1mo", interval: "1d", start: nil, fin: nil, prepost: false,
|
26
|
+
actions: true, auto_adjust: true, repair: false, proxy: nil,
|
27
|
+
threads: true, group_by: 'column', progress: true, timeout: 10, **kwargs)
|
28
|
+
data = Multi.download(@symbols, start: start, fin: fin, actions: actions,
|
29
|
+
auto_adjust: auto_adjust, repair: repair, period: period,
|
30
|
+
interval: interval, prepost: prepost, proxy: proxy,
|
31
|
+
group_by: 'ticker', threads: threads, progress: progress,
|
32
|
+
timeout: timeout, **kwargs)
|
33
|
+
|
34
|
+
@symbols.each do |symbol|
|
35
|
+
@tickers[symbol]._history = data[symbol] if @tickers[symbol]
|
36
|
+
end
|
37
|
+
|
38
|
+
if group_by == 'column'
|
39
|
+
data.columns = data.columns.swaplevel(0, 1)
|
40
|
+
data.sort_index(level: 0, axis: 1, inplace: true)
|
41
|
+
end
|
42
|
+
|
43
|
+
data
|
44
|
+
end
|
45
|
+
|
46
|
+
def news
|
47
|
+
@symbols.each_with_object({}) do |ticker, hash|
|
48
|
+
hash[ticker] = Ticker.new(ticker).news
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|