yf_as_dataframe 0.3.1 → 0.4.1

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.
@@ -1,10 +1,10 @@
1
1
  require 'polars'
2
2
  require 'polars-df'
3
+ require 'logger'
3
4
 
4
5
  class YfAsDataframe
5
6
  module PriceHistory
6
7
  extend ActiveSupport::Concern
7
- include ActionView::Helpers::NumberHelper
8
8
 
9
9
  PRICE_COLNAMES = ['Open', 'High', 'Low', 'Close', 'Adj Close']
10
10
  BASE_URL = 'https://query2.finance.yahoo.com'
@@ -35,10 +35,10 @@ class YfAsDataframe
35
35
  def history(period: "1mo", interval: "1d", start: nil, fin: nil, prepost: false,
36
36
  actions: true, auto_adjust: true, back_adjust: false, repair: false, keepna: false,
37
37
  rounding: false, raise_errors: false, returns: false)
38
- logger = Rails.logger # Yfin.get_yf_logger
38
+ logger = Logger.new(STDOUT) # Replace Rails.logger with standard Ruby logger
39
39
  start_user = start
40
40
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} here" }
41
- end_user = fin || DateTime.now
41
+ end_user = fin || Time.now
42
42
 
43
43
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} here" }
44
44
  params = _preprocess_params(start, fin, interval, period, prepost, raise_errors)
@@ -572,7 +572,7 @@ class YfAsDataframe
572
572
  if raise_errors
573
573
  raise Exception.new("#{@ticker}: #{err_msg}")
574
574
  else
575
- Rails.logger.error("#{@ticker}: #{err_msg}")
575
+ Logger.new(STDOUT).error("#{@ticker}: #{err_msg}")
576
576
  end
577
577
  return YfAsDataframe::Utils.empty_df
578
578
  end
@@ -585,7 +585,7 @@ class YfAsDataframe
585
585
  if interval == "1m"
586
586
  start = (fin - 1.week).to_i
587
587
  else
588
- max_start_datetime = (DateTime.now - (99.years)).to_i
588
+ max_start_datetime = (Time.now - (99.years)).to_i
589
589
  start = max_start_datetime.to_i
590
590
  end
591
591
  else
@@ -598,7 +598,7 @@ class YfAsDataframe
598
598
  else
599
599
  period = period.downcase
600
600
  # params = { "range" => period }
601
- fin = DateTime.now.to_i
601
+ fin = Time.now.to_i
602
602
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} here fin= #{fin}, period = #{period}" }
603
603
  start = (fin - YfAsDataframe::Utils.interval_to_timedelta(period)).to_i
604
604
  params = { "period1" => start, "period2" => fin }
@@ -617,31 +617,40 @@ class YfAsDataframe
617
617
  def _get_data(ticker, params, fin, raise_errors)
618
618
  url = "https://query2.finance.yahoo.com/v8/finance/chart/#{CGI.escape ticker}"
619
619
  # url = "https://query1.finance.yahoo.com/v7/finance/download/#{ticker}" ... Deprecated
620
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} url = #{url}" }
620
+ logger = Logger.new(STDOUT)
621
+ logger.info { "#{__FILE__}:#{__LINE__} url = #{url}" }
621
622
  data = nil
622
623
  # get_fn = @data.method(:get)
623
624
 
624
625
  if fin
625
626
  end_dt = DateTime.strptime(fin.to_s, '%s') #.new_offset(0)
626
- dt_now = DateTime.now #.new_offset(0)
627
+ dt_now = Time.now #.new_offset(0)
627
628
  data_delay = Rational(30, 24 * 60)
628
629
 
629
630
  # get_fn = @data.method(:cache_get) if end_dt + data_delay <= dt_now
630
631
  end
631
632
 
632
633
  begin
633
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} url = #{url}, params = #{params.inspect}" }
634
+ logger.info { "#{__FILE__}:#{__LINE__} url = #{url}, params = #{params.inspect}" }
634
635
  data = get(url, nil, params).parsed_response
635
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} data = #{data.inspect}" }
636
+ logger.info { "#{__FILE__}:#{__LINE__} data = #{data.inspect}" }
637
+
638
+ # Validate response before processing
639
+ unless validate_yahoo_response(data)
640
+ raise RuntimeError.new("Invalid response from Yahoo Finance: #{data.inspect}")
641
+ end
636
642
 
637
643
  raise RuntimeError.new(
638
644
  "*** YAHOO! FINANCE IS CURRENTLY DOWN! ***\n" +
639
645
  "Our engineers are working quickly to resolve the issue. Thank you for your patience."
640
- ) if data.text.include?("Will be right back") || data.nil?
646
+ ) if (data.is_a?(String) && data.include?("Will be right back")) || data.nil?
641
647
 
642
- data = HashWithIndifferentAccess.new(data)
643
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} data = #{data.inspect}" }
644
- rescue Exception
648
+ # Use standard Ruby Hash
649
+ data = data.is_a?(Hash) ? data : JSON.parse(data.to_s) rescue data
650
+ logger.info { "#{__FILE__}:#{__LINE__} data = #{data.inspect}" }
651
+ rescue Exception => e
652
+ logger.error { "#{__FILE__}:#{__LINE__} Exception caught: #{e.message}" }
653
+ logger.error { "#{__FILE__}:#{__LINE__} Exception backtrace: #{e.backtrace.first(5).join("\n")}" }
645
654
  raise if raise_errors
646
655
  end
647
656
 
@@ -710,7 +719,7 @@ class YfAsDataframe
710
719
  # startDt = quotes.index[0].floor('D')
711
720
  startDt = quotes['Timestamps'].to_a.map(&:to_date).min
712
721
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} startDt = #{startDt.inspect}" }
713
- endDt = fin.present? ? fin.to_date : Time.at(DateTime.now.tomorrow).to_i
722
+ endDt = !fin.nil? && !(fin.respond_to?(:empty?) && fin.empty?) ? fin.to_date : (Time.now + 86400).to_date
714
723
 
715
724
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} @history[events][dividends] = #{@history['events']["dividends"].inspect}" }
716
725
  # divi = {}
@@ -722,32 +731,32 @@ class YfAsDataframe
722
731
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} ts = #{ts.inspect}" }
723
732
  @history['events']["dividends"].select{|k,v|
724
733
  Time.at(k.to_i).utc.to_date >= startDt && Time.at(k.to_i).utc.to_date <= endDt }.each{|k,v|
725
- d[ts.index(Time.at(k.to_i).utc)] = v['amount'].to_f} unless @history.try(:[],'events').try(:[],"dividends").nil?
734
+ d[ts.index(Time.at(k.to_i).utc)] = v['amount'].to_f} unless @history.dig('events', 'dividends').nil?
726
735
  df['Dividends'] = Polars::Series.new(d)
727
736
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} df = #{df.inspect}" }
728
737
 
729
738
  # caga = {}
730
739
  # @history['events']["capital gains"].select{|k,v|
731
740
  # Time.at(k.to_i).utc.to_date >= startDt && Time.at(k.to_i).utc.to_date <= endDt }.each{|k,v|
732
- # caga['date'] = v['amount']} unless @history.try(:[],'events').try(:[],"capital gains").nil?
741
+ # caga['date'] = v['amount']} unless @history.dig('events', 'capital gains').nil?
733
742
  # capital_gains = capital_gains.loc[startDt:] if capital_gains.shape.first > 0
734
743
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} caga = #{caga.inspect}" }
735
744
  d = [0.0] * df.length
736
745
  @history['events']["capital gains"].select{|k,v|
737
746
  Time.at(k.to_i).utc.to_date >= startDt && Time.at(k.to_i).utc.to_date <= endDt }.each{|k,v|
738
- d[ts.index(Time.at(k.to_i).utc)] = v['amount'].to_f} unless @history.try(:[],'events').try(:[],"capital gains").nil?
747
+ d[ts.index(Time.at(k.to_i).utc)] = v['amount'].to_f} unless @history.dig('events', 'capital gains').nil?
739
748
  df['Capital Gains'] = Polars::Series.new(d)
740
749
 
741
750
  # splits = splits.loc[startDt:] if splits.shape[0] > 0
742
751
  # stspl = {}
743
752
  # @history['events']['stock splits'].select{|k,v|
744
753
  # Time.at(k.to_i).utc.to_date >= startDt && Time.at(k.to_i).utc.to_date <= endDt }.each{|k,v|
745
- # stspl['date'] = v['numerator'].to_f/v['denominator'].to_f} unless @history.try(:[],'events').try(:[],"stock splits").nil?
754
+ # stspl['date'] = v['numerator'].to_f/v['denominator'].to_f} unless @history.dig('events', 'capital gains').nil?
746
755
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} stspl = #{stspl.inspect}" }
747
756
  d = [0.0] * df.length
748
757
  @history['events']["capital gains"].select{|k,v|
749
758
  Time.at(k.to_i).utc.to_date >= startDt && Time.at(k.to_i).utc.to_date <= endDt }.each{|k,v|
750
- d[ts.index(Time.at(k.to_i).utc)] = v['numerator'].to_f/v['denominator'].to_f} unless @history.try(:[],'events').try(:[],"capital gains").nil?
759
+ d[ts.index(Time.at(k.to_i).utc)] = v['numerator'].to_f/v['denominator'].to_f} unless @history.dig('events', 'capital gains').nil?
751
760
  df['Stock Splits'] = Polars::Series.new(d)
752
761
  end
753
762
 
@@ -2019,7 +2028,7 @@ class YfAsDataframe
2019
2028
  end
2020
2029
 
2021
2030
  def _exchange_open_now
2022
- t = DateTime.now
2031
+ t = Time.now
2023
2032
  _get_exchange_metadata
2024
2033
 
2025
2034
  # if self._today_open is nil and self._today_close.nil?
@@ -2041,5 +2050,26 @@ class YfAsDataframe
2041
2050
  # print("_exchange_open_now returning", r)
2042
2051
  # return r
2043
2052
  end
2053
+
2054
+ private
2055
+
2056
+ def validate_yahoo_response(response)
2057
+ return false if response.nil?
2058
+ return false if response.is_a?(String) && response.include?("Too Many Requests")
2059
+ return false if response.is_a?(String) && response.include?("Will be right back")
2060
+ return false if response.is_a?(String) && response.include?("<html>")
2061
+ return false if response.is_a?(String) && response.strip.empty?
2062
+
2063
+ # If it's a Hash, validate it has the expected structure
2064
+ if response.is_a?(Hash)
2065
+ return false unless response.key?("chart")
2066
+ return false unless response["chart"].is_a?(Hash)
2067
+ return false unless response["chart"].key?("result")
2068
+ return false unless response["chart"]["result"].is_a?(Array)
2069
+ return false if response["chart"]["result"].empty?
2070
+ end
2071
+
2072
+ true
2073
+ end
2044
2074
  end
2045
2075
  end
@@ -3,7 +3,6 @@ require 'tulirb'
3
3
  class YfAsDataframe
4
4
  module PriceTechnical
5
5
  extend ActiveSupport::Concern
6
- include ActionView::Helpers::NumberHelper
7
6
 
8
7
 
9
8
  def ad(df)
@@ -1,4 +1,5 @@
1
1
  require 'httparty'
2
+ require 'logger'
2
3
 
3
4
  class YfAsDataframe
4
5
  module Quote
@@ -129,7 +130,7 @@ class YfAsDataframe
129
130
  result = get_raw_json(QUOTE_SUMMARY_URL + "/#{symbol}", user_agent_headers=user_agent_headers, params=params_dict)
130
131
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} result = #{result.inspect}" }
131
132
  rescue Exception => e
132
- Rails.logger.error("ERROR: #{e.message}")
133
+ Logger.new(STDOUT).error("ERROR: #{e.message}")
133
134
  return nil
134
135
  end
135
136
  return result
@@ -192,10 +193,10 @@ class YfAsDataframe
192
193
  keys.each { |k| url += "&type=" + k }
193
194
 
194
195
  # Request 6 months of data
195
- start = (DateTime.now.utc.midnight - 6.months).to_i #datetime.timedelta(days=365 // 2)
196
+ start = (Time.now.utc.midnight - 6.months).to_i #datetime.timedelta(days=365 // 2)
196
197
  # start = int(start.timestamp())
197
198
 
198
- ending = DateTime.now.utc.tomorrow.midnight.to_i
199
+ ending = Time.now.utc.tomorrow.midnight.to_i
199
200
  # ending = int(ending.timestamp())
200
201
  url += "&period1=#{start}&period2=#{ending}"
201
202
 
@@ -1,3 +1,6 @@
1
+ require 'tzinfo'
2
+ require 'logger'
3
+
1
4
  class YfAsDataframe
2
5
  class Ticker
3
6
  ROOT_URL = 'https://finance.yahoo.com'.freeze
@@ -44,28 +47,28 @@ class YfAsDataframe
44
47
  def symbol; @ticker; end
45
48
 
46
49
  def shares_full(start: nil, fin: nil)
47
- logger = Rails.logger
50
+ logger = Logger.new(STDOUT)
48
51
 
49
52
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
50
53
 
51
54
  if start
52
55
  start_ts = YfAsDataframe::Utils.parse_user_dt(start, tz)
53
56
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} start_ts = #{start_ts}" }
54
- start = Time.at(start_ts).in_time_zone(tz)
57
+ start = Time.at(start_ts)
55
58
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
56
59
  end
57
60
  if fin
58
61
  end_ts = YfAsDataframe::Utils.parse_user_dt(fin, tz)
59
62
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} end_ts = #{end_ts}" }
60
- fin = Time.at(end_ts).in_time_zone(tz)
63
+ fin = Time.at(end_ts)
61
64
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
62
65
  end
63
66
 
64
67
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} start = #{start.inspect}, fin = #{fin.inspect}" }
65
68
 
66
- dt_now = DateTime.now.in_time_zone(tz)
69
+ dt_now = Time.now
67
70
  fin ||= dt_now
68
- start ||= (fin - 548.days).midnight
71
+ start ||= Time.new(fin.year, fin.month, fin.day) - 548*24*60*60
69
72
 
70
73
  if start >= fin
71
74
  logger.error("Start date (#{start}) must be before end (#{fin})")
@@ -73,7 +76,7 @@ class YfAsDataframe
73
76
  end
74
77
 
75
78
  ts_url_base = "https://query2.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/#{@ticker}?symbol=#{@ticker}"
76
- shares_url = "#{ts_url_base}&period1=#{start.to_i}&period2=#{fin.tomorrow.midnight.to_i}"
79
+ shares_url = "#{ts_url_base}&period1=#{Time.new(start.year, start.month, start.day).to_i}&period2=#{Time.new((fin + 86400).year, (fin + 86400).month, (fin + 86400).day).to_i}"
77
80
 
78
81
  begin
79
82
  json_data = get(shares_url).parsed_response
@@ -92,7 +95,7 @@ class YfAsDataframe
92
95
 
93
96
  return nil if !shares_data[0].key?("shares_out")
94
97
 
95
- timestamps = shares_data[0]["timestamp"].map{|t| Time.at(t).to_datetime }
98
+ timestamps = shares_data[0]["timestamp"].map{|t| Time.at(t) }
96
99
 
97
100
  df = Polars::DataFrame.new(
98
101
  {
@@ -107,7 +110,7 @@ class YfAsDataframe
107
110
  def shares
108
111
  return @shares unless @shares.nil?
109
112
 
110
- full_shares = shares_full(start: DateTime.now.utc.to_date-548.days, fin: DateTime.now.utc.to_date)
113
+ full_shares = shares_full(start: Time.now.utc.to_date-548.days, fin: Time.now.utc.to_date)
111
114
  # Rails.logger.info { "#{__FILE__}:#{__LINE__} full_shares = #{full_shares.inspect}" }
112
115
 
113
116
  # if shares.nil?
@@ -150,7 +153,7 @@ class YfAsDataframe
150
153
  # """
151
154
  return @earnings_dates[limit] if @earnings_dates && @earnings_dates[limit]
152
155
 
153
- logger = Rails.logger
156
+ logger = Logger.new(STDOUT)
154
157
 
155
158
  page_size = [limit, 100].min # YF caps at 100, don't go higher
156
159
  page_offset = 0
@@ -247,13 +247,17 @@ class YfAsDataframe
247
247
 
248
248
  def self.parse_user_dt(dt, exchange_tz)
249
249
  if dt.is_a?(Integer)
250
- Time.at(dt)
250
+ return Time.at(dt)
251
251
  elsif dt.is_a?(String)
252
- dt = DateTime.strptime(dt.to_s, '%Y-%m-%d')
252
+ dt = DateTime.strptime(dt.to_s, '%Y-%m-%d')
253
253
  elsif dt.is_a?(Date)
254
- dt = dt.to_datetime
255
- elsif dt.is_a?(DateTime) && dt.zone.nil?
256
- dt = dt.in_time_zone(exchange_tz)
254
+ dt = dt.to_datetime
255
+ end
256
+ # If it's a DateTime, convert to Time
257
+ if dt.is_a?(DateTime)
258
+ # If zone is nil, try to set it, else just convert
259
+ dt = dt.in_time_zone(exchange_tz) if dt.zone.nil? && dt.respond_to?(:in_time_zone)
260
+ dt = dt.to_time
257
261
  end
258
262
  dt.to_i
259
263
  end
@@ -261,37 +265,80 @@ class YfAsDataframe
261
265
  def self.interval_to_timedelta(interval)
262
266
  case interval
263
267
  when '1mo'
264
- 1.month
268
+ # Calculate 1 month from now to get accurate days
269
+ now = Time.now
270
+ next_month = Time.new(now.year, now.month + 1, now.day)
271
+ # Handle year rollover
272
+ next_month = Time.new(now.year + 1, 1, now.day) if next_month.month == 1
273
+ (next_month - now).to_i
265
274
  when '2mo'
266
- 2.months
275
+ # Calculate 2 months from now
276
+ now = Time.now
277
+ next_month = Time.new(now.year, now.month + 2, now.day)
278
+ # Handle year rollover
279
+ next_month = Time.new(now.year + 1, next_month.month, now.day) if next_month.month <= 2
280
+ (next_month - now).to_i
267
281
  when '3mo'
268
- 3.months
282
+ # Calculate 3 months from now
283
+ now = Time.now
284
+ next_month = Time.new(now.year, now.month + 3, now.day)
285
+ # Handle year rollover
286
+ next_month = Time.new(now.year + 1, next_month.month, now.day) if next_month.month <= 3
287
+ (next_month - now).to_i
269
288
  when '6mo'
270
- 6.months
289
+ # Calculate 6 months from now
290
+ now = Time.now
291
+ next_month = Time.new(now.year, now.month + 6, now.day)
292
+ # Handle year rollover
293
+ next_month = Time.new(now.year + 1, next_month.month, now.day) if next_month.month <= 6
294
+ (next_month - now).to_i
271
295
  when '9mo'
272
- 9.months
296
+ # Calculate 9 months from now
297
+ now = Time.now
298
+ next_month = Time.new(now.year, now.month + 9, now.day)
299
+ # Handle year rollover
300
+ next_month = Time.new(now.year + 1, next_month.month, now.day) if next_month.month <= 9
301
+ (next_month - now).to_i
273
302
  when '12mo'
274
- 1.year
303
+ # Calculate 12 months (1 year) from now
304
+ now = Time.now
305
+ next_year = Time.new(now.year + 1, now.month, now.day)
306
+ (next_year - now).to_i
275
307
  when '1y'
276
- 1.year
308
+ # Calculate 1 year from now
309
+ now = Time.now
310
+ next_year = Time.new(now.year + 1, now.month, now.day)
311
+ (next_year - now).to_i
277
312
  when '2y'
278
- 2.year
313
+ # Calculate 2 years from now
314
+ now = Time.now
315
+ next_year = Time.new(now.year + 2, now.month, now.day)
316
+ (next_year - now).to_i
279
317
  when '3y'
280
- 3.year
318
+ # Calculate 3 years from now
319
+ now = Time.now
320
+ next_year = Time.new(now.year + 3, now.month, now.day)
321
+ (next_year - now).to_i
281
322
  when '4y'
282
- 4.year
323
+ # Calculate 4 years from now
324
+ now = Time.now
325
+ next_year = Time.new(now.year + 4, now.month, now.day)
326
+ (next_year - now).to_i
283
327
  when '5y'
284
- 5.year
328
+ # Calculate 5 years from now
329
+ now = Time.now
330
+ next_year = Time.new(now.year + 5, now.month, now.day)
331
+ (next_year - now).to_i
285
332
  when '1wk'
286
- 1.week
333
+ 7.days
287
334
  when '2wk'
288
- 2.week
335
+ 14.days
289
336
  when '3wk'
290
- 3.week
337
+ 21.days
291
338
  when '4wk'
292
- 4.week
339
+ 28.days
293
340
  else
294
- Rails.logger.warn { "#{__FILE__}:#{__LINE__} #{interval} not a recognized interval" }
341
+ # Logger.new(STDOUT).warn { "#{__FILE__}:#{__LINE__} #{interval} not a recognized interval" }
295
342
  interval
296
343
  end
297
344
  end
@@ -1,3 +1,3 @@
1
1
  class YfAsDataframe
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.1"
3
3
  end