yfinrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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