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
data/lib/yfinrb/quote.rb
ADDED
@@ -0,0 +1,342 @@
|
|
1
|
+
|
2
|
+
class Yfin
|
3
|
+
module Quote
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def self.included(base) # built-in Ruby hook for modules
|
7
|
+
base.class_eval do
|
8
|
+
original_method = instance_method(:initialize)
|
9
|
+
define_method(:initialize) do |*args, &block|
|
10
|
+
original_method.bind(self).call(*args, &block)
|
11
|
+
initialize_quote # (your module code here)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize_quote
|
17
|
+
@info = nil
|
18
|
+
@retired_info = nil
|
19
|
+
@sustainability = nil
|
20
|
+
@recommendations = nil
|
21
|
+
@upgrades_downgrades = nil
|
22
|
+
@calendar = nil
|
23
|
+
|
24
|
+
@already_scraped = false
|
25
|
+
@already_fetched = false
|
26
|
+
@already_fetched_complementary = false
|
27
|
+
end
|
28
|
+
|
29
|
+
def info #(self)
|
30
|
+
if @info.nil?
|
31
|
+
_fetch_info() #(@proxy)
|
32
|
+
_fetch_complementary() #(@proxy)
|
33
|
+
end
|
34
|
+
return @info
|
35
|
+
end
|
36
|
+
|
37
|
+
def sustainability
|
38
|
+
raise YFNotImplementedError.new('sustainability') if @sustainability.nil?
|
39
|
+
return @sustainability
|
40
|
+
end
|
41
|
+
|
42
|
+
def recommendations
|
43
|
+
Polars::Config.set_tbl_rows(-1)
|
44
|
+
if @recommendations.nil?
|
45
|
+
|
46
|
+
result = _fetch(['recommendationTrend']).parsed_response
|
47
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} result = #{result.inspect}" }
|
48
|
+
# if result.nil?
|
49
|
+
# @recommendations = Utils.empty_df() #Polars::DataFrame()
|
50
|
+
# else
|
51
|
+
begin
|
52
|
+
data = result["quoteSummary"]["result"][0]["recommendationTrend"]["trend"]
|
53
|
+
rescue KeyError, IndexError => e
|
54
|
+
raise YfinDataException(f"Failed to parse json response from Yahoo Finance: #{e.result}")
|
55
|
+
end
|
56
|
+
@recommendations = Polars::DataFrame.new(data)
|
57
|
+
# end
|
58
|
+
end
|
59
|
+
return @recommendations
|
60
|
+
end
|
61
|
+
|
62
|
+
alias_method :recommendation_summary, :recommendations
|
63
|
+
alias_method :recommendations_summary, :recommendations
|
64
|
+
|
65
|
+
def upgrades_downgrades
|
66
|
+
Polars::Config.set_tbl_rows(-1)
|
67
|
+
if @upgrades_downgrades.nil?
|
68
|
+
result = _fetch(['upgradeDowngradeHistory']).parsed_response
|
69
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} result = #{result.inspect}" }
|
70
|
+
|
71
|
+
# if result.nil?
|
72
|
+
# @upgrades_downgrades = Utils.empty_df() #Polars::DataFrame()
|
73
|
+
# else
|
74
|
+
begin
|
75
|
+
data = result["quoteSummary"]["result"][0]["upgradeDowngradeHistory"]["history"]
|
76
|
+
|
77
|
+
raise YfinDataException("No upgrade/downgrade history found for #{ticker.symbol}") if (data).length.zero?
|
78
|
+
|
79
|
+
df = Polars::DataFrame.new(data)
|
80
|
+
df.rename({"epochGradeDate" => "GradeDate", 'firm' => 'Firm', 'toGrade' => 'ToGrade', 'fromGrade' => 'FromGrade', 'action' => 'Action'})
|
81
|
+
# df.set_index('GradeDate', inplace=true)
|
82
|
+
# df.index = pd.to_datetime(df.index, unit='s')
|
83
|
+
@upgrades_downgrades = df
|
84
|
+
rescue KeyError, IndexError => e
|
85
|
+
raise YfinDataException("Failed to parse json response from Yahoo Finance: #{e.result}")
|
86
|
+
end
|
87
|
+
# end
|
88
|
+
end
|
89
|
+
return @upgrades_downgrades
|
90
|
+
end
|
91
|
+
|
92
|
+
def calendar
|
93
|
+
self._fetch_calendar() if @calendar.nil?
|
94
|
+
return @calendar
|
95
|
+
end
|
96
|
+
|
97
|
+
def valid_modules()
|
98
|
+
return QUOTE_SUMMARY_VALID_MODULES
|
99
|
+
end
|
100
|
+
|
101
|
+
# quote_methods = [:info, :sustainability, :recommendations, :recommendations_summary, :recommendation_summary, \
|
102
|
+
# :upgrades_downgrades, :calendar]
|
103
|
+
# quote_methods.each do |meth|
|
104
|
+
# # define_method "get_#{meth}".to_sym do
|
105
|
+
# # data = @quote.send(meth.to_sym)
|
106
|
+
# # return data
|
107
|
+
# # end
|
108
|
+
# alias_method "get_#{meth}".to_sym, meth
|
109
|
+
# end
|
110
|
+
|
111
|
+
|
112
|
+
|
113
|
+
|
114
|
+
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def _fetch(modules) #(self, proxy, modules: list)
|
119
|
+
# raise YahooFinanceException("Should provide a list of modules, see available modules using `valid_modules`") if !modules.is_a?(Array)
|
120
|
+
|
121
|
+
modules = modules.intersection(QUOTE_SUMMARY_VALID_MODULES) #[m for m in modules if m in quote_summary_valid_modules])
|
122
|
+
|
123
|
+
raise YahooFinanceException("No valid modules provided.") if modules.empty?
|
124
|
+
|
125
|
+
params_dict = {"modules": modules.join(','), "corsDomain": "finance.yahoo.com", "formatted": "false", "symbol": symbol}
|
126
|
+
|
127
|
+
begin
|
128
|
+
result = get_raw_json(QUOTE_SUMMARY_URL + "/#{symbol}", user_agent_headers=user_agent_headers, params=params_dict)
|
129
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} result = #{result.inspect}" }
|
130
|
+
rescue Exception => e
|
131
|
+
Rails.logger.error("ERROR: #{e.message}")
|
132
|
+
return nil
|
133
|
+
end
|
134
|
+
return result
|
135
|
+
end
|
136
|
+
|
137
|
+
def _format(k, v)
|
138
|
+
v2 = nil
|
139
|
+
if isinstance(v, dict) && "raw".in?(v) && "fmt".in?(v)
|
140
|
+
v2 = k.in?(["regularMarketTime", "postMarketTime"]) ? v["fmt"] : v["raw"]
|
141
|
+
elsif isinstance(v, list)
|
142
|
+
v2 = v.map{|vv| _format(nil, vv)}
|
143
|
+
elsif isinstance(v, dict)
|
144
|
+
v2 = v.items().map{|k,x| _format(k,x) } #{k: _format(k, x) for k, x in v.items()}
|
145
|
+
elsif isinstance(v, str)
|
146
|
+
v2 = v.replace("\xa0", " ")
|
147
|
+
else
|
148
|
+
v2 = v
|
149
|
+
end
|
150
|
+
return v2
|
151
|
+
|
152
|
+
query1_info.items().each do |k,v|
|
153
|
+
query1_info[k] = _format(k, v)
|
154
|
+
@info = query1_info
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def _fetch_complementary() #(proxy) # (self, proxy)
|
159
|
+
return if @already_fetched_complementary
|
160
|
+
|
161
|
+
# self._scrape(proxy) # decrypt broken
|
162
|
+
self._fetch_info() #(proxy)
|
163
|
+
return if @info.nil?
|
164
|
+
|
165
|
+
# Complementary key-statistics. For now just want 'trailing PEG ratio'
|
166
|
+
keys = ["trailingPegRatio"] #{"trailingPegRatio"}
|
167
|
+
if keys
|
168
|
+
# Simplified the original scrape code for key-statistics. Very expensive for fetching
|
169
|
+
# just one value, best if scraping most/all:
|
170
|
+
#
|
171
|
+
# p = _re.compile(r'root\.App\.main = (.*);')
|
172
|
+
# url = 'https://finance.yahoo.com/quote/{}/key-statistics?p={}'.format(self._ticker.ticker, self._ticker.ticker)
|
173
|
+
# try:
|
174
|
+
# r = session.get(url, headers=utils.user_agent_headers)
|
175
|
+
# data = _json.loads(p.findall(r.text)[0])
|
176
|
+
# key_stats = data['context']['dispatcher']['stores']['QuoteTimeSeriesStore']["timeSeries"]
|
177
|
+
# for k in keys:
|
178
|
+
# if k not in key_stats or len(key_stats[k])==0:
|
179
|
+
# # Yahoo website prints N/A, indicates Yahoo lacks necessary data to calculate
|
180
|
+
# v = nil
|
181
|
+
# else:
|
182
|
+
# # Select most recent (last) raw value in list:
|
183
|
+
# v = key_stats[k][-1]["reportedValue"]["raw"]
|
184
|
+
# self._info[k] = v
|
185
|
+
# except Exception:
|
186
|
+
# raise
|
187
|
+
# pass
|
188
|
+
#
|
189
|
+
# For just one/few variable is faster to query directly:
|
190
|
+
url = "https://query1.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/#{symbol}?symbol=#{symbol}"
|
191
|
+
keys.each { |k| url += "&type=" + k }
|
192
|
+
|
193
|
+
# Request 6 months of data
|
194
|
+
start = (DateTime.now.utc.midnight - 6.months).to_i #datetime.timedelta(days=365 // 2)
|
195
|
+
# start = int(start.timestamp())
|
196
|
+
|
197
|
+
ending = DateTime.now.utc.tomorrow.midnight.to_i
|
198
|
+
# ending = int(ending.timestamp())
|
199
|
+
url += "&period1=#{start}&period2=#{ending}"
|
200
|
+
|
201
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} url = #{url}" }
|
202
|
+
json_str = get(url).parsed_response # , proxy=proxy).parsed_response #@data.cache_get(url=url, proxy=proxy).text
|
203
|
+
json_data = json_str #json.loads(json_str)
|
204
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} json_data = #{json_data.inspect}" }
|
205
|
+
json_result = json_data.try(:[],"timeseries") or json_data.try(:[], "finance")
|
206
|
+
unless json_result["error"].nil?
|
207
|
+
raise YfinException("Failed to parse json response from Yahoo Finance: #{json_result["error"]}")
|
208
|
+
|
209
|
+
keys.each do |k|
|
210
|
+
keydict = json_result["result"][0]
|
211
|
+
|
212
|
+
@info[k] = k.in?(keydict) ? keydict[k][-1]["reportedValue"]["raw"] : nil
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
@already_fetched_complementary = true
|
218
|
+
end
|
219
|
+
|
220
|
+
def _fetch_calendar
|
221
|
+
begin
|
222
|
+
# secFilings return too old data, so not requesting it for now
|
223
|
+
result = self._fetch(['calendarEvents']) #(@proxy, modules=['calendarEvents'])
|
224
|
+
if result.nil?
|
225
|
+
@calendar = {}
|
226
|
+
return
|
227
|
+
end
|
228
|
+
|
229
|
+
rescue KeyError, IndexError => e
|
230
|
+
raise YfinDataException("Failed to parse json response from Yahoo Finance: #{e.result}")
|
231
|
+
rescue => e
|
232
|
+
@calendar = {} #dict()
|
233
|
+
_events = result["quoteSummary"]["result"][0]["calendarEvents"]
|
234
|
+
if 'dividendDate'.in?(_events)
|
235
|
+
@calendar['Dividend Date'] = datetime.datetime.fromtimestamp(_events['dividendDate']).date()
|
236
|
+
if 'exDividendDate'.in?(_events)
|
237
|
+
@calendar['Ex-Dividend Date'] = datetime.datetime.fromtimestamp(_events['exDividendDate']).date()
|
238
|
+
# splits = _events.get('splitDate') # need to check later, i will add code for this if found data
|
239
|
+
earnings = _events.get('earnings')
|
240
|
+
if !earnings.nil?
|
241
|
+
@calendar['Earnings Date'] = earnings.get('earningsDate', []).map{|d| d.to_date } # [datetime.datetime.fromtimestamp(d).date() for d in earnings.get('earningsDate', [])]
|
242
|
+
@calendar['Earnings High'] = earnings.get('earningsHigh', nil)
|
243
|
+
@calendar['Earnings Low'] = earnings.get('earningsLow', nil)
|
244
|
+
@calendar['Earnings Average'] = earnings.get('earningsAverage', nil)
|
245
|
+
@calendar['Revenue High'] = earnings.get('revenueHigh', nil)
|
246
|
+
@calendar['Revenue Low'] = earnings.get('revenueLow', nil)
|
247
|
+
@calendar['Revenue Average'] = earnings.get('revenueAverage', nil)
|
248
|
+
# Likely need to decipher and restore the following
|
249
|
+
# except
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def _fetch_info()
|
257
|
+
return if @already_fetched
|
258
|
+
|
259
|
+
modules = ['financialData', 'quoteType', 'defaultKeyStatistics', 'assetProfile', 'summaryDetail']
|
260
|
+
|
261
|
+
result = _fetch(modules)
|
262
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} result = #{result.inspect}" }
|
263
|
+
if result.parsed_response.nil?
|
264
|
+
@info = {}
|
265
|
+
return
|
266
|
+
end
|
267
|
+
|
268
|
+
result["quoteSummary"]["result"][0]["symbol"] = symbol
|
269
|
+
|
270
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} result[quoteSummary][result] = #{result["quoteSummary"]["result"].inspect}" }
|
271
|
+
# Rails.logger.info { "#{__FILE__}:#{__LINE__} result[quoteSummary][result].first.keys = #{result["quoteSummary"]["result"].first.keys.inspect}" }
|
272
|
+
query1_info = result["quoteSummary"]["result"].first
|
273
|
+
|
274
|
+
# Likely need to decipher and restore the following
|
275
|
+
# query1_info = next(
|
276
|
+
# (info for info in result.get("quoteSummary", {}).get("result", []) if info["symbol"] == @symbol),
|
277
|
+
# nil,
|
278
|
+
# )
|
279
|
+
|
280
|
+
# Most keys that appear in multiple dicts have same value. Except 'maxAge' because
|
281
|
+
# Yahoo not consistent with days vs seconds. Fix it here:
|
282
|
+
|
283
|
+
query1_info.keys.each {|k|
|
284
|
+
query1_info[k]["maxAge"] = 86400 if "maxAge".in?(query1_info[k]) && query1_info[k]["maxAge"] == 1
|
285
|
+
}
|
286
|
+
|
287
|
+
# Likely need to decipher and restore the following
|
288
|
+
# query1_info = {
|
289
|
+
# k1: v1
|
290
|
+
# for k, v in query1_info.items()
|
291
|
+
# if isinstance(v, dict)
|
292
|
+
# for k1, v1 in v.items()
|
293
|
+
# if v1
|
294
|
+
# }
|
295
|
+
# # recursively format but only because of 'companyOfficers'
|
296
|
+
|
297
|
+
@info = query1_info
|
298
|
+
@already_fetched = true
|
299
|
+
end
|
300
|
+
|
301
|
+
|
302
|
+
BASE_URL = 'https://query2.finance.yahoo.com'
|
303
|
+
QUOTE_SUMMARY_URL = "#{BASE_URL}/v10/finance/quoteSummary"
|
304
|
+
|
305
|
+
QUOTE_SUMMARY_VALID_MODULES = [
|
306
|
+
"summaryProfile", # contains general information about the company
|
307
|
+
"summaryDetail", # prices + volume + market cap + etc
|
308
|
+
"assetProfile", # summaryProfile + company officers
|
309
|
+
"fundProfile",
|
310
|
+
"price", # current prices
|
311
|
+
"quoteType", # quoteType
|
312
|
+
"esgScores", # Environmental, social, and governance (ESG) scores, sustainability and ethical performance of companies
|
313
|
+
"incomeStatementHistory",
|
314
|
+
"incomeStatementHistoryQuarterly",
|
315
|
+
"balanceSheetHistory",
|
316
|
+
"balanceSheetHistoryQuarterly",
|
317
|
+
"cashFlowStatementHistory",
|
318
|
+
"cashFlowStatementHistoryQuarterly",
|
319
|
+
"defaultKeyStatistics", # KPIs (PE, enterprise value, EPS, EBITA, and more)
|
320
|
+
"financialData", # Financial KPIs (revenue, gross margins, operating cash flow, free cash flow, and more)
|
321
|
+
"calendarEvents", # future earnings date
|
322
|
+
"secFilings", # SEC filings, such as 10K and 10Q reports
|
323
|
+
"upgradeDowngradeHistory", # upgrades and downgrades that analysts have given a company's stock
|
324
|
+
"institutionOwnership", # institutional ownership, holders and shares outstanding
|
325
|
+
"fundOwnership", # mutual fund ownership, holders and shares outstanding
|
326
|
+
"majorDirectHolders",
|
327
|
+
"majorHoldersBreakdown",
|
328
|
+
"insiderTransactions", # insider transactions, such as the number of shares bought and sold by company executives
|
329
|
+
"insiderHolders", # insider holders, such as the number of shares held by company executives
|
330
|
+
"netSharePurchaseActivity", # net share purchase activity, such as the number of shares bought and sold by company executives
|
331
|
+
"earnings", # earnings history
|
332
|
+
"earningsHistory",
|
333
|
+
"earningsTrend", # earnings trend
|
334
|
+
"industryTrend",
|
335
|
+
"indexTrend",
|
336
|
+
"sectorTrend",
|
337
|
+
"recommendationTrend",
|
338
|
+
"futuresChain",
|
339
|
+
].freeze
|
340
|
+
|
341
|
+
end
|
342
|
+
end
|