Class: SQA::Stock

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/sqa/stock.rb

Overview

Represents a stock with price history, metadata, and technical analysis capabilities. This is the primary domain object for interacting with stock data.

Examples:

Basic usage

stock = SQA::Stock.new(ticker: 'AAPL')
prices = stock.df["adj_close_price"].to_a
puts stock.to_s

With different data source

stock = SQA::Stock.new(ticker: 'MSFT', source: :yahoo_finance)

Constant Summary collapse

ALPHA_VANTAGE_URL =

Default Alpha Vantage API URL

Returns:

  • (String)

    The base URL for Alpha Vantage API

"https://www.alphavantage.co".freeze
CONNECTION =
Deprecated.

Use connection method instead. Will be removed in v1.0.0

Returns Legacy constant for backward compatibility.

Returns:

  • (Faraday::Connection)

    Legacy constant for backward compatibility

Faraday.new(url: ALPHA_VANTAGE_URL)

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(ticker:, source: :alpha_vantage) ⇒ Stock

Creates a new Stock instance and loads or fetches its data.

Examples:

stock = SQA::Stock.new(ticker: 'AAPL')
stock = SQA::Stock.new(ticker: 'GOOG', source: :yahoo_finance)

Parameters:

  • ticker (String)

    The stock ticker symbol (e.g., ‘AAPL’, ‘MSFT’)

  • source (Symbol) (defaults to: :alpha_vantage)

    The data source to use (:alpha_vantage or :yahoo_finance)

Raises:



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/sqa/stock.rb', line 81

def initialize(ticker:, source: :alpha_vantage)
  @ticker = ticker.downcase
  @source = source

  @data_path = SQA.data_dir + "#{@ticker}.json"
  @df_path = SQA.data_dir + "#{@ticker}.csv"

  # Validate ticker if validation data is available and cached data doesn't exist
  unless @data_path.exist? && @df_path.exist?
    unless SQA::Ticker.valid?(ticker)
      warn "Warning: Ticker #{ticker} could not be validated. Proceeding anyway." if $VERBOSE
    end
  end

  @klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
  @transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize

  load_or_create_data
  update_dataframe
end

Instance Attribute Details

#dataSQA::DataFrame::Data

Returns Stock metadata (ticker, name, exchange, etc.).

Returns:



69
70
71
# File 'lib/sqa/stock.rb', line 69

def data
  @data
end

#dfSQA::DataFrame

Returns Price and volume data as a DataFrame.

Returns:



69
# File 'lib/sqa/stock.rb', line 69

attr_accessor :data, :df, :klass, :transformers, :strategy

#klassClass

Returns The data source class (e.g., SQA::DataFrame::AlphaVantage).

Returns:

  • (Class)

    The data source class (e.g., SQA::DataFrame::AlphaVantage)



69
# File 'lib/sqa/stock.rb', line 69

attr_accessor :data, :df, :klass, :transformers, :strategy

#strategySQA::Strategy?

Returns Optional trading strategy attached to this stock.

Returns:

  • (SQA::Strategy, nil)

    Optional trading strategy attached to this stock



69
# File 'lib/sqa/stock.rb', line 69

attr_accessor :data, :df, :klass, :transformers, :strategy

#transformersHash

Returns Column transformers for data normalization.

Returns:

  • (Hash)

    Column transformers for data normalization



69
# File 'lib/sqa/stock.rb', line 69

attr_accessor :data, :df, :klass, :transformers, :strategy

Class Method Details

.connectionFaraday::Connection

Returns the current Faraday connection for API requests. Allows injection of custom connections for testing or different configurations.

Returns:

  • (Faraday::Connection)

    The current connection instance



30
31
32
# File 'lib/sqa/stock.rb', line 30

def connection
  @connection ||= default_connection
end

.connection=(conn) ⇒ Faraday::Connection

Sets a custom Faraday connection. Useful for testing with mocks/stubs or configuring different API endpoints.

Parameters:

  • conn (Faraday::Connection)

    Custom Faraday connection to use

Returns:

  • (Faraday::Connection)

    The connection that was set



39
40
41
# File 'lib/sqa/stock.rb', line 39

def connection=(conn)
  @connection = conn
end

.default_connectionFaraday::Connection

Creates the default Faraday connection to Alpha Vantage.

Returns:

  • (Faraday::Connection)

    A new connection to Alpha Vantage API



46
47
48
# File 'lib/sqa/stock.rb', line 46

def default_connection
  Faraday.new(url: ALPHA_VANTAGE_URL)
end

.reset_connection!nil

Resets the connection to default. Useful for testing cleanup to ensure fresh state between tests.

Returns:

  • (nil)


54
55
56
# File 'lib/sqa/stock.rb', line 54

def reset_connection!
  @connection = nil
end

.reset_top!nil

Resets the cached top gainers/losers data. Useful for testing or forcing a refresh.

Returns:

  • (nil)


383
384
385
# File 'lib/sqa/stock.rb', line 383

def reset_top!
  @top = nil
end

.topHashie::Mash

Fetches top gainers, losers, and most actively traded stocks from Alpha Vantage. Results are cached after the first call.

Examples:

top = SQA::Stock.top
top.top_gainers.each { |stock| puts "#{stock.ticker}: +#{stock.change_percentage}%" }
top.top_losers.first.ticker  # => "XYZ"

Returns:

  • (Hashie::Mash)

    Object with top_gainers, top_losers, and most_actively_traded arrays



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/sqa/stock.rb', line 352

def top
  return @top if @top

  a_hash = JSON.parse(connection.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])

  mash = Hashie::Mash.new(a_hash)

  keys = mash.top_gainers.first.keys

  %w[top_gainers top_losers most_actively_traded].each do |collection|
    mash.send(collection).each do |e|
      keys.each do |k|
        case k
        when 'ticker'
          # Leave it as a String
        when 'volume'
          e[k] = e[k].to_i
        else
          e[k] = e[k].to_f
        end
      end
    end
  end

  @top = mash
end

Instance Method Details

#create_dataSQA::DataFrame::Data

Creates a new minimal data structure for the stock.

Returns:



126
127
128
# File 'lib/sqa/stock.rb', line 126

def create_data
  @data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {})
end

#exchangeString?

Returns The exchange where the stock trades.

Returns:

  • (String, nil)

    The exchange where the stock trades



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#indicatorsHash

Returns Cached indicator values.

Returns:

  • (Hash)

    Cached indicator values



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#indicators=(value) ⇒ Object

Parameters:

  • value (Hash)

    New indicator values



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#load_or_create_datavoid

This method returns an undefined value.

Loads existing data from cache or creates new data structure. If cached data exists, loads from JSON file. Otherwise creates minimal structure and attempts to fetch overview from API.



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/sqa/stock.rb', line 107

def load_or_create_data
  if @data_path.exist?
    @data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
  else
    # Create minimal data structure
    create_data

    # Try to fetch overview data, but don't fail if we can't
    # This is optional metadata - we can work with just price data
    update

    # Save whatever data we have (even if overview fetch failed)
    save_data
  end
end

#merge_overviewHash

Fetches and merges company overview data from Alpha Vantage API. Converts API response keys to snake_case and appropriate data types.

Returns:

  • (Hash)

    The merged overview data

Raises:

  • (ApiError)

    If the API returns an error response



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/sqa/stock.rb', line 317

def merge_overview
  temp = JSON.parse(
    self.class.connection.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
    .to_hash[:body]
  )

  if temp.key?("Information")
    ApiError.raise(temp["Information"])
  end

  temp2 = {}
  string_values = %w[address asset_type cik country currency description dividend_date ex_dividend_date exchange fiscal_year_end industry latest_quarter name sector symbol]

  temp.keys.each do |k|
    new_k = k.underscore
    temp2[new_k] = string_values.include?(new_k) ? temp[k] : temp[k].to_f
  end

  @data.overview = temp2
end

#nameString?

Returns The company name.

Returns:

  • (String, nil)

    The company name



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#overviewHash?

Returns Company overview data from API.

Returns:

  • (Hash, nil)

    Company overview data from API



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#save_dataInteger

Persists the stock’s metadata to a JSON file.

Returns:

  • (Integer)

    Number of bytes written



158
159
160
# File 'lib/sqa/stock.rb', line 158

def save_data
  @data_path.write(@data.to_json)
end

#should_update?Boolean

Determines whether the DataFrame should be updated from the API. Returns false if lazy_update is enabled, API key is missing, or data is already current.

Returns:

  • (Boolean)

    true if update should proceed, false otherwise



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/sqa/stock.rb', line 271

def should_update?
  # Don't update if we're in lazy update mode
  return false if SQA.config.lazy_update

  # Don't update if we don't have an API key (only relevant for Alpha Vantage)
  if @source == :alpha_vantage
    begin
      SQA.av_api_key
    rescue SQA::ConfigurationError
      return false
    end
  end

  # Don't update if CSV data is already current (last timestamp is today or later)
  # This prevents unnecessary API calls when we already have today's data
  if @df && @df.size > 0
    begin
      last_timestamp = Date.parse(@df["timestamp"].to_a.last)
      return false if last_timestamp >= Date.today
    rescue ArgumentError, Date::Error => e
      # If we can't parse the date, assume we need to update
      warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
    end
  end

  true
end

#sourceSymbol

Returns The data source (:alpha_vantage or :yahoo_finance).

Returns:

  • (Symbol)

    The data source (:alpha_vantage or :yahoo_finance)



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#tickerString

Returns The stock’s ticker symbol.

Returns:

  • (String)

    The stock’s ticker symbol



176
# File 'lib/sqa/stock.rb', line 176

def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview

#to_sString Also known as: inspect

Returns a human-readable string representation of the stock.

Examples:

stock.to_s  # => "aapl with 252 data points from 2023-01-03 to 2023-12-29"

Returns:

  • (String)

    Summary including ticker, data points count, and date range



305
306
307
# File 'lib/sqa/stock.rb', line 305

def to_s
  "#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
end

#updatevoid

This method returns an undefined value.

Updates the stock’s overview data from the API. Silently handles errors since overview data is optional.

Examples:

Update stock metadata from API

stock = SQA::Stock.new(ticker: 'AAPL')
stock.update  # Fetches latest company overview data
stock.data.overview['market_capitalization']  # => 2500000000000
stock.data.overview['pe_ratio']  # => 28.5

Update is safe if API fails

stock.update  # No error raised if API is unavailable
# Warning logged but stock remains usable with cached data


145
146
147
148
149
150
151
152
153
# File 'lib/sqa/stock.rb', line 145

def update
  begin
    merge_overview
  rescue StandardError => e
    # Log warning but don't fail - overview data is optional
    # Common causes: rate limits, network issues, API errors
    warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
  end
end

#update_dataframevoid

This method returns an undefined value.

Updates the DataFrame with price data. Loads from cache if available, otherwise fetches from API. Applies migrations for old data formats and updates with recent data.

Raises:



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/sqa/stock.rb', line 184

def update_dataframe
  if @df_path.exist?
    # Load cached CSV - transformers already applied when data was first fetched
    # Don't reapply them as columns are already in correct format
    @df = SQA::DataFrame.load(source: @df_path)

    migrated = false

    # Migration 1: Rename old column names to new convention
    # Old files may have: open, high, low, close
    # New files should have: open_price, high_price, low_price, close_price
    if @df.columns.include?("open") && !@df.columns.include?("open_price")
      old_to_new_mapping = {
        "open"   => "open_price",
        "high"   => "high_price",
        "low"    => "low_price",
        "close"  => "close_price"
      }
      @df.rename_columns!(old_to_new_mapping)
      migrated = true
    end

    # Migration 2: Add adj_close_price column if missing (for old cached files)
    # This ensures compatibility when appending new data that includes this column
    unless @df.columns.include?("adj_close_price")
      @df.data = @df.data.with_column(
        @df.data["close_price"].alias("adj_close_price")
      )
      migrated = true
    end

    # Save migrated DataFrame to avoid repeating migration
    @df.to_csv(@df_path) if migrated
  else
    # Fetch fresh data from source (applies transformers and mapping)
    begin
      @df = @klass.recent(@ticker, full: true)
      @df.to_csv(@df_path)
      return
    rescue StandardError => e
      # If we can't fetch data, raise a more helpful error
      raise SQA::DataFetchError.new(
        "Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}",
        original: e
      )
    end
  end

  update_dataframe_with_recent_data
end

#update_dataframe_with_recent_datavoid

This method returns an undefined value.

Fetches recent data from API and appends to existing DataFrame. Only called if should_update? returns true.



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/sqa/stock.rb', line 239

def update_dataframe_with_recent_data
  return unless should_update?

  begin
    # CSV is sorted ascending (oldest first, TA-Lib compatible), so .last gets the most recent date
    from_date = Date.parse(@df["timestamp"].to_a.last)
    df2 = @klass.recent(@ticker, from_date: from_date)

    if df2 && (df2.size > 0)
      # Use concat_and_deduplicate! to prevent duplicate timestamps and maintain ascending sort
      @df.concat_and_deduplicate!(df2)
      @df.to_csv(@df_path)
    end
  rescue StandardError => e
    # Log warning but don't fail - we have cached data
    # Common causes: rate limits, network issues, API errors
    warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
  end
end

#update_the_dataframevoid

Deprecated.

Use #update_dataframe instead. Will be removed in v1.0.0

This method returns an undefined value.



261
262
263
264
# File 'lib/sqa/stock.rb', line 261

def update_the_dataframe
  warn "[SQA DEPRECATION] update_the_dataframe is deprecated; use update_dataframe instead" if $VERBOSE
  update_dataframe
end