yfinrb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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