sqa 0.0.32 → 0.0.38

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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +154 -1
  3. data/README.md +4 -0
  4. data/Rakefile +52 -10
  5. data/docs/advanced/index.md +1 -13
  6. data/docs/api/index.md +547 -61
  7. data/docs/api-reference/alphavantageapi.md +1057 -0
  8. data/docs/api-reference/apierror.md +31 -0
  9. data/docs/api-reference/index.md +221 -0
  10. data/docs/api-reference/notimplemented.md +27 -0
  11. data/docs/api-reference/sqa.md +267 -0
  12. data/docs/api-reference/sqa_backtest.md +171 -0
  13. data/docs/api-reference/sqa_backtest_results.md +530 -0
  14. data/docs/api-reference/sqa_badparametererror.md +13 -0
  15. data/docs/api-reference/sqa_config.md +538 -0
  16. data/docs/api-reference/sqa_configurationerror.md +13 -0
  17. data/docs/api-reference/sqa_datafetcherror.md +56 -0
  18. data/docs/api-reference/sqa_dataframe.md +779 -0
  19. data/docs/api-reference/sqa_dataframe_alphavantage.md +30 -0
  20. data/docs/api-reference/sqa_dataframe_data.md +325 -0
  21. data/docs/api-reference/sqa_dataframe_yahoofinance.md +25 -0
  22. data/docs/api-reference/sqa_ensemble.md +413 -0
  23. data/docs/api-reference/sqa_fpop.md +211 -0
  24. data/docs/api-reference/sqa_geneticprogram.md +325 -0
  25. data/docs/api-reference/sqa_geneticprogram_individual.md +114 -0
  26. data/docs/api-reference/sqa_marketregime.md +212 -0
  27. data/docs/api-reference/sqa_multitimeframe.md +227 -0
  28. data/docs/api-reference/sqa_patternmatcher.md +195 -0
  29. data/docs/api-reference/sqa_pluginmanager.md +55 -0
  30. data/docs/api-reference/sqa_portfolio.md +512 -0
  31. data/docs/api-reference/sqa_portfolio_position.md +220 -0
  32. data/docs/api-reference/sqa_portfolio_trade.md +332 -0
  33. data/docs/api-reference/sqa_portfoliooptimizer.md +248 -0
  34. data/docs/api-reference/sqa_riskmanager.md +388 -0
  35. data/docs/api-reference/sqa_seasonalanalyzer.md +121 -0
  36. data/docs/api-reference/sqa_sectoranalyzer.md +163 -0
  37. data/docs/api-reference/sqa_stock.md +661 -0
  38. data/docs/api-reference/sqa_strategy.md +178 -0
  39. data/docs/api-reference/sqa_strategy_bollingerbands.md +26 -0
  40. data/docs/api-reference/sqa_strategy_common.md +29 -0
  41. data/docs/api-reference/sqa_strategy_consensus.md +129 -0
  42. data/docs/api-reference/sqa_strategy_ema.md +41 -0
  43. data/docs/api-reference/sqa_strategy_kbs.md +154 -0
  44. data/docs/api-reference/sqa_strategy_macd.md +26 -0
  45. data/docs/api-reference/sqa_strategy_mp.md +41 -0
  46. data/docs/api-reference/sqa_strategy_mr.md +41 -0
  47. data/docs/api-reference/sqa_strategy_random.md +41 -0
  48. data/docs/api-reference/sqa_strategy_rsi.md +41 -0
  49. data/docs/api-reference/sqa_strategy_sma.md +41 -0
  50. data/docs/api-reference/sqa_strategy_stochastic.md +26 -0
  51. data/docs/api-reference/sqa_strategy_volumebreakout.md +26 -0
  52. data/docs/api-reference/sqa_strategygenerator.md +298 -0
  53. data/docs/api-reference/sqa_strategygenerator_pattern.md +264 -0
  54. data/docs/api-reference/sqa_strategygenerator_patterncontext.md +326 -0
  55. data/docs/api-reference/sqa_strategygenerator_profitablepoint.md +424 -0
  56. data/docs/api-reference/sqa_stream.md +256 -0
  57. data/docs/api-reference/sqa_ticker.md +175 -0
  58. data/docs/api-reference/string.md +135 -0
  59. data/docs/assets/images/advanced-workflow.svg +89 -0
  60. data/docs/assets/images/architecture.svg +107 -0
  61. data/docs/assets/images/data-flow.svg +138 -0
  62. data/docs/assets/images/getting-started-workflow.svg +88 -0
  63. data/docs/assets/images/strategy-flow.svg +78 -0
  64. data/docs/assets/images/system-architecture.svg +150 -0
  65. data/docs/concepts/index.md +292 -19
  66. data/docs/file_formats.md +250 -0
  67. data/docs/getting-started/index.md +1 -14
  68. data/docs/index.md +26 -23
  69. data/docs/llms.txt +109 -0
  70. data/docs/strategies/kbs.md +15 -14
  71. data/docs/strategy.md +381 -3
  72. data/docs/terms_of_use.md +1 -1
  73. data/examples/README.md +10 -0
  74. data/lib/api/alpha_vantage_api.rb +3 -7
  75. data/lib/sqa/backtest.rb +32 -0
  76. data/lib/sqa/config.rb +109 -28
  77. data/lib/sqa/data_frame/data.rb +13 -1
  78. data/lib/sqa/data_frame.rb +193 -26
  79. data/lib/sqa/errors.rb +79 -17
  80. data/lib/sqa/init.rb +70 -15
  81. data/lib/sqa/pattern_matcher.rb +4 -4
  82. data/lib/sqa/portfolio.rb +55 -1
  83. data/lib/sqa/sector_analyzer.rb +3 -11
  84. data/lib/sqa/stock.rb +180 -15
  85. data/lib/sqa/strategy.rb +62 -4
  86. data/lib/sqa/ticker.rb +106 -48
  87. data/lib/sqa/version.rb +1 -1
  88. data/lib/sqa.rb +4 -4
  89. data/mkdocs.yml +69 -81
  90. metadata +89 -21
  91. data/docs/README.md +0 -43
  92. data/examples/sinatra_app/Gemfile +0 -42
  93. data/examples/sinatra_app/Gemfile.lock +0 -268
  94. data/examples/sinatra_app/QUICKSTART.md +0 -169
  95. data/examples/sinatra_app/README.md +0 -471
  96. data/examples/sinatra_app/RUNNING_WITHOUT_TALIB.md +0 -90
  97. data/examples/sinatra_app/TROUBLESHOOTING.md +0 -95
  98. data/examples/sinatra_app/app.rb +0 -404
  99. data/examples/sinatra_app/config.ru +0 -5
  100. data/examples/sinatra_app/public/css/style.css +0 -723
  101. data/examples/sinatra_app/public/debug_macd.html +0 -82
  102. data/examples/sinatra_app/public/js/app.js +0 -107
  103. data/examples/sinatra_app/start.sh +0 -53
  104. data/examples/sinatra_app/views/analyze.erb +0 -306
  105. data/examples/sinatra_app/views/backtest.erb +0 -325
  106. data/examples/sinatra_app/views/dashboard.erb +0 -831
  107. data/examples/sinatra_app/views/error.erb +0 -58
  108. data/examples/sinatra_app/views/index.erb +0 -118
  109. data/examples/sinatra_app/views/layout.erb +0 -61
  110. data/examples/sinatra_app/views/portfolio.erb +0 -43
data/lib/sqa/portfolio.rb CHANGED
@@ -5,7 +5,7 @@ require 'date'
5
5
  require 'csv'
6
6
 
7
7
  class SQA::Portfolio
8
- attr_accessor :positions, :trades, :cash, :initial_cash
8
+ attr_accessor :positions, :trades, :cash, :initial_cash, :commission
9
9
 
10
10
  # Represents a single position in the portfolio
11
11
  Position = Struct.new(:ticker, :shares, :avg_cost, :total_cost) do
@@ -52,6 +52,19 @@ class SQA::Portfolio
52
52
  # @param price [Float] Price per share
53
53
  # @param date [Date] Date of trade
54
54
  # @return [Trade] The executed trade
55
+ #
56
+ # @example Buy 10 shares of AAPL
57
+ # portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
58
+ # trade = portfolio.buy('AAPL', shares: 10, price: 150.0)
59
+ # trade.action # => :buy
60
+ # trade.total # => 1500.0
61
+ # portfolio.cash # => 8499.0 (10_000 - 1500 - 1.0 commission)
62
+ #
63
+ # @example Buy multiple stocks
64
+ # portfolio.buy('AAPL', shares: 10, price: 150.0)
65
+ # portfolio.buy('MSFT', shares: 5, price: 300.0)
66
+ # portfolio.positions.size # => 2
67
+ #
55
68
  def buy(ticker, shares:, price:, date: Date.today)
56
69
  raise BadParameterError, "Shares must be positive" if shares <= 0
57
70
  raise BadParameterError, "Price must be positive" if price <= 0
@@ -95,6 +108,19 @@ class SQA::Portfolio
95
108
  # @param price [Float] Price per share
96
109
  # @param date [Date] Date of trade
97
110
  # @return [Trade] The executed trade
111
+ #
112
+ # @example Sell entire position for profit
113
+ # portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
114
+ # portfolio.buy('AAPL', shares: 10, price: 150.0)
115
+ # trade = portfolio.sell('AAPL', shares: 10, price: 160.0)
116
+ # trade.total # => 1600.0
117
+ # portfolio.cash # => 8498.0 + 1599.0 = 10097.0 (after commissions)
118
+ #
119
+ # @example Partial sale
120
+ # portfolio.buy('AAPL', shares: 100, price: 150.0)
121
+ # portfolio.sell('AAPL', shares: 50, price: 160.0) # Sell half
122
+ # portfolio.position('AAPL').shares # => 50
123
+ #
98
124
  def sell(ticker, shares:, price:, date: Date.today)
99
125
  raise BadParameterError, "Shares must be positive" if shares <= 0
100
126
  raise BadParameterError, "Price must be positive" if price <= 0
@@ -145,6 +171,18 @@ class SQA::Portfolio
145
171
  # Calculate total portfolio value
146
172
  # @param current_prices [Hash] Hash of ticker => current_price
147
173
  # @return [Float] Total portfolio value (cash + positions)
174
+ #
175
+ # @example Calculate portfolio value with current prices
176
+ # portfolio = SQA::Portfolio.new(initial_cash: 10_000)
177
+ # portfolio.buy('AAPL', shares: 10, price: 150.0)
178
+ # portfolio.buy('MSFT', shares: 5, price: 300.0)
179
+ #
180
+ # current_prices = { 'AAPL' => 160.0, 'MSFT' => 310.0 }
181
+ # portfolio.value(current_prices) # => 10_000 - 1500 - 1500 + 1600 + 1550 = 10_150
182
+ #
183
+ # @example Without current prices (uses avg_cost)
184
+ # portfolio.value # Uses purchase prices if no current prices provided
185
+ #
148
186
  def value(current_prices = {})
149
187
  positions_value = @positions.sum do |ticker, pos|
150
188
  current_price = current_prices[ticker] || pos.avg_cost
@@ -186,6 +224,22 @@ class SQA::Portfolio
186
224
  # Get summary statistics
187
225
  # @param current_prices [Hash] Hash of ticker => current_price
188
226
  # @return [Hash] Summary statistics
227
+ #
228
+ # @example Get portfolio performance summary
229
+ # portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
230
+ # portfolio.buy('AAPL', shares: 10, price: 150.0)
231
+ # portfolio.sell('AAPL', shares: 5, price: 160.0)
232
+ #
233
+ # summary = portfolio.summary({ 'AAPL' => 165.0 })
234
+ # summary[:initial_cash] # => 10_000.0
235
+ # summary[:current_cash] # => 8798.0
236
+ # summary[:positions_count] # => 1
237
+ # summary[:total_value] # => 9623.0
238
+ # summary[:profit_loss_percent] # => -3.77%
239
+ # summary[:total_trades] # => 2
240
+ # summary[:buy_trades] # => 1
241
+ # summary[:sell_trades] # => 1
242
+ #
189
243
  def summary(current_prices = {})
190
244
  {
191
245
  initial_cash: @initial_cash,
@@ -89,15 +89,11 @@ module SQA
89
89
  kb = @blackboards[sector]
90
90
  all_patterns = []
91
91
 
92
- puts "=" * 70
93
- puts "Discovering patterns for #{sector.to_s.upcase} sector"
94
- puts "Analyzing #{stocks.size} stocks: #{stocks.map(&:ticker).join(', ')}"
95
- puts "=" * 70
96
- puts
92
+ debug_me {"Discovering patterns for #{sector.to_s.upcase} sector - #{stocks.size} stocks"}
97
93
 
98
94
  # Discover patterns for each stock
99
95
  stocks.each do |stock|
100
- puts "\nAnalyzing #{stock.ticker}..."
96
+ debug_me {"Analyzing #{stock.ticker}..."}
101
97
 
102
98
  generator = SQA::StrategyGenerator.new(stock: stock, **options)
103
99
  patterns = generator.discover_patterns
@@ -136,11 +132,7 @@ module SQA
136
132
  })
137
133
  end
138
134
 
139
- puts "\n" + "=" * 70
140
- puts "Sector Analysis Complete"
141
- puts " Individual patterns found: #{all_patterns.size}"
142
- puts " Sector-wide patterns: #{sector_patterns.size}"
143
- puts "=" * 70
135
+ debug_me {"Sector Analysis Complete - #{all_patterns.size} individual, #{sector_patterns.size} sector-wide patterns"}
144
136
 
145
137
  sector_patterns
146
138
  end
data/lib/sqa/stock.rb CHANGED
@@ -1,12 +1,83 @@
1
1
  # lib/sqa/stock.rb
2
2
 
3
+ # Represents a stock with price history, metadata, and technical analysis capabilities.
4
+ # This is the primary domain object for interacting with stock data.
5
+ #
6
+ # @example Basic usage
7
+ # stock = SQA::Stock.new(ticker: 'AAPL')
8
+ # prices = stock.df["adj_close_price"].to_a
9
+ # puts stock.to_s
10
+ #
11
+ # @example With different data source
12
+ # stock = SQA::Stock.new(ticker: 'MSFT', source: :yahoo_finance)
13
+ #
3
14
  class SQA::Stock
4
15
  extend Forwardable
5
16
 
6
- CONNECTION = Faraday.new(url: "https://www.alphavantage.co")
17
+ # Default Alpha Vantage API URL
18
+ # @return [String] The base URL for Alpha Vantage API
19
+ ALPHA_VANTAGE_URL = "https://www.alphavantage.co".freeze
7
20
 
21
+ # @deprecated Use {.connection} method instead. Will be removed in v1.0.0
22
+ # @return [Faraday::Connection] Legacy constant for backward compatibility
23
+ CONNECTION = Faraday.new(url: ALPHA_VANTAGE_URL)
24
+
25
+ class << self
26
+ # Returns the current Faraday connection for API requests.
27
+ # Allows injection of custom connections for testing or different configurations.
28
+ #
29
+ # @return [Faraday::Connection] The current connection instance
30
+ def connection
31
+ @connection ||= default_connection
32
+ end
33
+
34
+ # Sets a custom Faraday connection.
35
+ # Useful for testing with mocks/stubs or configuring different API endpoints.
36
+ #
37
+ # @param conn [Faraday::Connection] Custom Faraday connection to use
38
+ # @return [Faraday::Connection] The connection that was set
39
+ def connection=(conn)
40
+ @connection = conn
41
+ end
42
+
43
+ # Creates the default Faraday connection to Alpha Vantage.
44
+ #
45
+ # @return [Faraday::Connection] A new connection to Alpha Vantage API
46
+ def default_connection
47
+ Faraday.new(url: ALPHA_VANTAGE_URL)
48
+ end
49
+
50
+ # Resets the connection to default.
51
+ # Useful for testing cleanup to ensure fresh state between tests.
52
+ #
53
+ # @return [nil]
54
+ def reset_connection!
55
+ @connection = nil
56
+ end
57
+ end
58
+
59
+ # @!attribute [rw] data
60
+ # @return [SQA::DataFrame::Data] Stock metadata (ticker, name, exchange, etc.)
61
+ # @!attribute [rw] df
62
+ # @return [SQA::DataFrame] Price and volume data as a DataFrame
63
+ # @!attribute [rw] klass
64
+ # @return [Class] The data source class (e.g., SQA::DataFrame::AlphaVantage)
65
+ # @!attribute [rw] transformers
66
+ # @return [Hash] Column transformers for data normalization
67
+ # @!attribute [rw] strategy
68
+ # @return [SQA::Strategy, nil] Optional trading strategy attached to this stock
8
69
  attr_accessor :data, :df, :klass, :transformers, :strategy
9
70
 
71
+ # Creates a new Stock instance and loads or fetches its data.
72
+ #
73
+ # @param ticker [String] The stock ticker symbol (e.g., 'AAPL', 'MSFT')
74
+ # @param source [Symbol] The data source to use (:alpha_vantage or :yahoo_finance)
75
+ # @raise [SQA::DataFetchError] If data cannot be fetched and no cached data exists
76
+ #
77
+ # @example
78
+ # stock = SQA::Stock.new(ticker: 'AAPL')
79
+ # stock = SQA::Stock.new(ticker: 'GOOG', source: :yahoo_finance)
80
+ #
10
81
  def initialize(ticker:, source: :alpha_vantage)
11
82
  @ticker = ticker.downcase
12
83
  @source = source
@@ -25,9 +96,14 @@ class SQA::Stock
25
96
  @transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
26
97
 
27
98
  load_or_create_data
28
- update_the_dataframe
99
+ update_dataframe
29
100
  end
30
101
 
102
+ # Loads existing data from cache or creates new data structure.
103
+ # If cached data exists, loads from JSON file. Otherwise creates
104
+ # minimal structure and attempts to fetch overview from API.
105
+ #
106
+ # @return [void]
31
107
  def load_or_create_data
32
108
  if @data_path.exist?
33
109
  @data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
@@ -44,27 +120,68 @@ class SQA::Stock
44
120
  end
45
121
  end
46
122
 
123
+ # Creates a new minimal data structure for the stock.
124
+ #
125
+ # @return [SQA::DataFrame::Data] The newly created data object
47
126
  def create_data
48
- @data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: { xyzzy: "Magic" })
127
+ @data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {})
49
128
  end
50
129
 
130
+ # Updates the stock's overview data from the API.
131
+ # Silently handles errors since overview data is optional.
132
+ #
133
+ # @return [void]
134
+ #
135
+ # @example Update stock metadata from API
136
+ # stock = SQA::Stock.new(ticker: 'AAPL')
137
+ # stock.update # Fetches latest company overview data
138
+ # stock.data.overview['market_capitalization'] # => 2500000000000
139
+ # stock.data.overview['pe_ratio'] # => 28.5
140
+ #
141
+ # @example Update is safe if API fails
142
+ # stock.update # No error raised if API is unavailable
143
+ # # Warning logged but stock remains usable with cached data
144
+ #
51
145
  def update
52
146
  begin
53
147
  merge_overview
54
- rescue => e
148
+ rescue StandardError => e
55
149
  # Log warning but don't fail - overview data is optional
56
150
  # Common causes: rate limits, network issues, API errors
57
151
  warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
58
152
  end
59
153
  end
60
154
 
155
+ # Persists the stock's metadata to a JSON file.
156
+ #
157
+ # @return [Integer] Number of bytes written
61
158
  def save_data
62
159
  @data_path.write(@data.to_json)
63
160
  end
64
161
 
162
+ # @!method ticker
163
+ # @return [String] The stock's ticker symbol
164
+ # @!method name
165
+ # @return [String, nil] The company name
166
+ # @!method exchange
167
+ # @return [String, nil] The exchange where the stock trades
168
+ # @!method source
169
+ # @return [Symbol] The data source (:alpha_vantage or :yahoo_finance)
170
+ # @!method indicators
171
+ # @return [Hash] Cached indicator values
172
+ # @!method indicators=(value)
173
+ # @param value [Hash] New indicator values
174
+ # @!method overview
175
+ # @return [Hash, nil] Company overview data from API
65
176
  def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
66
177
 
67
- def update_the_dataframe
178
+ # Updates the DataFrame with price data.
179
+ # Loads from cache if available, otherwise fetches from API.
180
+ # Applies migrations for old data formats and updates with recent data.
181
+ #
182
+ # @return [void]
183
+ # @raise [SQA::DataFetchError] If data cannot be fetched and no cache exists
184
+ def update_dataframe
68
185
  if @df_path.exist?
69
186
  # Load cached CSV - transformers already applied when data was first fetched
70
187
  # Don't reapply them as columns are already in correct format
@@ -103,15 +220,22 @@ class SQA::Stock
103
220
  @df = @klass.recent(@ticker, full: true)
104
221
  @df.to_csv(@df_path)
105
222
  return
106
- rescue => e
223
+ rescue StandardError => e
107
224
  # If we can't fetch data, raise a more helpful error
108
- raise "Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}"
225
+ raise SQA::DataFetchError.new(
226
+ "Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}",
227
+ original: e
228
+ )
109
229
  end
110
230
  end
111
231
 
112
232
  update_dataframe_with_recent_data
113
233
  end
114
234
 
235
+ # Fetches recent data from API and appends to existing DataFrame.
236
+ # Only called if should_update? returns true.
237
+ #
238
+ # @return [void]
115
239
  def update_dataframe_with_recent_data
116
240
  return unless should_update?
117
241
 
@@ -125,13 +249,25 @@ class SQA::Stock
125
249
  @df.concat_and_deduplicate!(df2)
126
250
  @df.to_csv(@df_path)
127
251
  end
128
- rescue => e
252
+ rescue StandardError => e
129
253
  # Log warning but don't fail - we have cached data
130
254
  # Common causes: rate limits, network issues, API errors
131
255
  warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
132
256
  end
133
257
  end
134
258
 
259
+ # @deprecated Use {#update_dataframe} instead. Will be removed in v1.0.0
260
+ # @return [void]
261
+ def update_the_dataframe
262
+ warn "[SQA DEPRECATION] update_the_dataframe is deprecated; use update_dataframe instead" if $VERBOSE
263
+ update_dataframe
264
+ end
265
+
266
+ # Determines whether the DataFrame should be updated from the API.
267
+ # Returns false if lazy_update is enabled, API key is missing,
268
+ # or data is already current.
269
+ #
270
+ # @return [Boolean] true if update should proceed, false otherwise
135
271
  def should_update?
136
272
  # Don't update if we're in lazy update mode
137
273
  return false if SQA.config.lazy_update
@@ -140,7 +276,7 @@ class SQA::Stock
140
276
  if @source == :alpha_vantage
141
277
  begin
142
278
  SQA.av_api_key
143
- rescue
279
+ rescue SQA::ConfigurationError
144
280
  return false
145
281
  end
146
282
  end
@@ -151,7 +287,7 @@ class SQA::Stock
151
287
  begin
152
288
  last_timestamp = Date.parse(@df["timestamp"].to_a.last)
153
289
  return false if last_timestamp >= Date.today
154
- rescue => e
290
+ rescue ArgumentError, Date::Error => e
155
291
  # If we can't parse the date, assume we need to update
156
292
  warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
157
293
  end
@@ -160,6 +296,12 @@ class SQA::Stock
160
296
  true
161
297
  end
162
298
 
299
+ # Returns a human-readable string representation of the stock.
300
+ #
301
+ # @return [String] Summary including ticker, data points count, and date range
302
+ #
303
+ # @example
304
+ # stock.to_s # => "aapl with 252 data points from 2023-01-03 to 2023-12-29"
163
305
  def to_s
164
306
  "#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
165
307
  end
@@ -167,13 +309,18 @@ class SQA::Stock
167
309
  # This ensures compatibility with TA-Lib indicators which expect arrays in this order
168
310
  alias_method :inspect, :to_s
169
311
 
312
+ # Fetches and merges company overview data from Alpha Vantage API.
313
+ # Converts API response keys to snake_case and appropriate data types.
314
+ #
315
+ # @return [Hash] The merged overview data
316
+ # @raise [ApiError] If the API returns an error response
170
317
  def merge_overview
171
318
  temp = JSON.parse(
172
- CONNECTION.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
319
+ self.class.connection.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
173
320
  .to_hash[:body]
174
321
  )
175
322
 
176
- if temp.has_key? "Information"
323
+ if temp.key?("Information")
177
324
  ApiError.raise(temp["Information"])
178
325
  end
179
326
 
@@ -192,10 +339,20 @@ class SQA::Stock
192
339
  ## Class Methods
193
340
 
194
341
  class << self
342
+ # Fetches top gainers, losers, and most actively traded stocks from Alpha Vantage.
343
+ # Results are cached after the first call.
344
+ #
345
+ # @return [Hashie::Mash] Object with top_gainers, top_losers, and most_actively_traded arrays
346
+ #
347
+ # @example
348
+ # top = SQA::Stock.top
349
+ # top.top_gainers.each { |stock| puts "#{stock.ticker}: +#{stock.change_percentage}%" }
350
+ # top.top_losers.first.ticker # => "XYZ"
351
+ #
195
352
  def top
196
- return @@top unless @@top.nil?
353
+ return @top if @top
197
354
 
198
- a_hash = JSON.parse(CONNECTION.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
355
+ a_hash = JSON.parse(connection.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
199
356
 
200
357
  mash = Hashie::Mash.new(a_hash)
201
358
 
@@ -216,7 +373,15 @@ class SQA::Stock
216
373
  end
217
374
  end
218
375
 
219
- @@top = mash
376
+ @top = mash
377
+ end
378
+
379
+ # Resets the cached top gainers/losers data.
380
+ # Useful for testing or forcing a refresh.
381
+ #
382
+ # @return [nil]
383
+ def reset_top!
384
+ @top = nil
220
385
  end
221
386
  end
222
387
  end
data/lib/sqa/strategy.rb CHANGED
@@ -1,16 +1,45 @@
1
1
  # lib/sqa/strategy.rb
2
2
 
3
+ # Framework for managing and executing trading strategies.
4
+ # Strategies are pluggable modules that generate buy/sell/hold signals.
5
+ #
6
+ # @example Basic usage
7
+ # strategy = SQA::Strategy.new
8
+ # strategy.add(SQA::Strategy::RSI)
9
+ # strategy.add(SQA::Strategy::MACD)
10
+ # signals = strategy.execute(vector) # => [:buy, :hold]
11
+ #
12
+ # @example Auto-loading strategies
13
+ # strategy = SQA::Strategy.new
14
+ # strategy.auto_load(except: [:random])
15
+ #
3
16
  class SQA::Strategy
17
+ # @!attribute [rw] strategies
18
+ # @return [Array<Method>] Collection of strategy trade methods
4
19
  attr_accessor :strategies
5
20
 
21
+ # Creates a new Strategy instance with an empty strategies collection.
6
22
  def initialize
7
23
  @strategies = []
8
24
  end
9
25
 
26
+ # Adds a trading strategy to the collection.
27
+ # Strategies must be either a Class with a .trade method or a Method object.
28
+ #
29
+ # @param a_strategy [Class, Method] Strategy to add
30
+ # @return [Array<Method>] Updated strategies collection
31
+ # @raise [BadParameterError] If strategy is not a Class or Method
32
+ #
33
+ # @example Adding a class-based strategy
34
+ # strategy.add(SQA::Strategy::RSI)
35
+ #
36
+ # @example Adding a method directly
37
+ # strategy.add(MyModule.method(:custom_trade))
38
+ #
10
39
  def add(a_strategy)
11
- raise BadParameterError unless [Class, Method].include? a_strategy.class
40
+ raise BadParameterError unless a_strategy.is_a?(Class) || a_strategy.is_a?(Method)
12
41
 
13
- a_proc = if Class == a_strategy.class
42
+ a_proc = if a_strategy.is_a?(Class)
14
43
  a_strategy.method(:trade)
15
44
  else
16
45
  a_strategy
@@ -19,13 +48,34 @@ class SQA::Strategy
19
48
  @strategies << a_proc
20
49
  end
21
50
 
51
+ # Executes all registered strategies with the given data vector.
52
+ #
53
+ # @param v [OpenStruct] Data vector containing indicator values and prices
54
+ # @return [Array<Symbol>] Array of signals (:buy, :sell, or :hold) from each strategy
55
+ #
56
+ # @example
57
+ # vector = OpenStruct.new(rsi: 25, prices: prices_array)
58
+ # signals = strategy.execute(vector) # => [:buy, :hold, :sell]
59
+ #
22
60
  def execute(v)
23
61
  result = []
24
- # TODO: Can do this in parallel ...
62
+ # NOTE: Could be parallelized with Parallel gem for large strategy sets
25
63
  @strategies.each { |signal| result << signal.call(v) }
26
64
  result
27
65
  end
28
66
 
67
+ # Auto-loads strategy files from the strategy directory.
68
+ #
69
+ # @param except [Array<Symbol>] Strategy names to exclude (default: [:common])
70
+ # @param only [Array<Symbol>] If provided, only load these strategies
71
+ # @return [nil]
72
+ #
73
+ # @example Load all except random
74
+ # strategy.auto_load(except: [:common, :random])
75
+ #
76
+ # @example Load only specific strategies
77
+ # strategy.auto_load(only: [:rsi, :macd])
78
+ #
29
79
  def auto_load(except: [:common], only: [])
30
80
  dir_path = Pathname.new(__dir__) + "strategy"
31
81
  except = Array(except).map{|f| f.to_s.downcase}
@@ -47,9 +97,17 @@ class SQA::Strategy
47
97
  nil
48
98
  end
49
99
 
100
+ # Returns all available strategy classes in the SQA::Strategy namespace.
101
+ #
102
+ # @return [Array<Class>] Array of strategy classes
103
+ #
104
+ # @example
105
+ # SQA::Strategy.new.available
106
+ # # => [SQA::Strategy::RSI, SQA::Strategy::MACD, ...]
107
+ #
50
108
  def available
51
109
  ObjectSpace.each_object(Class).select { |klass|
52
- klass.to_s.start_with?("SQA::Strategy::")
110
+ klass.name&.start_with?("SQA::Strategy::")
53
111
  }
54
112
  end
55
113
  end