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,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