sqa 0.0.31 → 0.0.37

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/CLAUDE.md +21 -0
  4. data/README.md +60 -32
  5. data/Rakefile +52 -10
  6. data/docs/IMPROVEMENT_PLAN.md +531 -0
  7. data/docs/advanced/index.md +1 -13
  8. data/docs/api/dataframe.md +0 -1
  9. data/docs/api/index.md +547 -61
  10. data/docs/api-reference/alphavantageapi.md +1057 -0
  11. data/docs/api-reference/apierror.md +31 -0
  12. data/docs/api-reference/index.md +221 -0
  13. data/docs/api-reference/notimplemented.md +27 -0
  14. data/docs/api-reference/sqa.md +267 -0
  15. data/docs/api-reference/sqa_backtest.md +137 -0
  16. data/docs/api-reference/sqa_backtest_results.md +530 -0
  17. data/docs/api-reference/sqa_badparametererror.md +13 -0
  18. data/docs/api-reference/sqa_config.md +538 -0
  19. data/docs/api-reference/sqa_configurationerror.md +13 -0
  20. data/docs/api-reference/sqa_datafetcherror.md +56 -0
  21. data/docs/api-reference/sqa_dataframe.md +752 -0
  22. data/docs/api-reference/sqa_dataframe_alphavantage.md +30 -0
  23. data/docs/api-reference/sqa_dataframe_data.md +325 -0
  24. data/docs/api-reference/sqa_dataframe_yahoofinance.md +25 -0
  25. data/docs/api-reference/sqa_ensemble.md +413 -0
  26. data/docs/api-reference/sqa_fpop.md +211 -0
  27. data/docs/api-reference/sqa_geneticprogram.md +325 -0
  28. data/docs/api-reference/sqa_geneticprogram_individual.md +114 -0
  29. data/docs/api-reference/sqa_marketregime.md +212 -0
  30. data/docs/api-reference/sqa_multitimeframe.md +227 -0
  31. data/docs/api-reference/sqa_patternmatcher.md +195 -0
  32. data/docs/api-reference/sqa_pluginmanager.md +55 -0
  33. data/docs/api-reference/sqa_portfolio.md +455 -0
  34. data/docs/api-reference/sqa_portfolio_position.md +220 -0
  35. data/docs/api-reference/sqa_portfolio_trade.md +332 -0
  36. data/docs/api-reference/sqa_portfoliooptimizer.md +248 -0
  37. data/docs/api-reference/sqa_riskmanager.md +388 -0
  38. data/docs/api-reference/sqa_seasonalanalyzer.md +121 -0
  39. data/docs/api-reference/sqa_sectoranalyzer.md +163 -0
  40. data/docs/api-reference/sqa_stock.md +649 -0
  41. data/docs/api-reference/sqa_strategy.md +178 -0
  42. data/docs/api-reference/sqa_strategy_bollingerbands.md +26 -0
  43. data/docs/api-reference/sqa_strategy_common.md +29 -0
  44. data/docs/api-reference/sqa_strategy_consensus.md +129 -0
  45. data/docs/api-reference/sqa_strategy_ema.md +41 -0
  46. data/docs/api-reference/sqa_strategy_kbs.md +154 -0
  47. data/docs/api-reference/sqa_strategy_macd.md +26 -0
  48. data/docs/api-reference/sqa_strategy_mp.md +41 -0
  49. data/docs/api-reference/sqa_strategy_mr.md +41 -0
  50. data/docs/api-reference/sqa_strategy_random.md +41 -0
  51. data/docs/api-reference/sqa_strategy_rsi.md +41 -0
  52. data/docs/api-reference/sqa_strategy_sma.md +41 -0
  53. data/docs/api-reference/sqa_strategy_stochastic.md +26 -0
  54. data/docs/api-reference/sqa_strategy_volumebreakout.md +26 -0
  55. data/docs/api-reference/sqa_strategygenerator.md +298 -0
  56. data/docs/api-reference/sqa_strategygenerator_pattern.md +264 -0
  57. data/docs/api-reference/sqa_strategygenerator_patterncontext.md +326 -0
  58. data/docs/api-reference/sqa_strategygenerator_profitablepoint.md +424 -0
  59. data/docs/api-reference/sqa_stream.md +256 -0
  60. data/docs/api-reference/sqa_ticker.md +175 -0
  61. data/docs/api-reference/string.md +135 -0
  62. data/docs/assets/images/advanced-workflow.svg +89 -0
  63. data/docs/assets/images/architecture.svg +107 -0
  64. data/docs/assets/images/data-flow.svg +138 -0
  65. data/docs/assets/images/getting-started-workflow.svg +88 -0
  66. data/docs/assets/images/sqa.jpg +0 -0
  67. data/docs/assets/images/strategy-flow.svg +78 -0
  68. data/docs/assets/images/system-architecture.svg +150 -0
  69. data/docs/concepts/index.md +292 -27
  70. data/docs/data_frame.md +0 -1
  71. data/docs/getting-started/index.md +1 -30
  72. data/docs/getting-started/installation.md +2 -2
  73. data/docs/getting-started/quick-start.md +4 -4
  74. data/docs/index.md +26 -25
  75. data/docs/llms.txt +109 -0
  76. data/docs/strategies/bollinger-bands.md +1 -1
  77. data/docs/strategies/kbs.md +15 -14
  78. data/docs/strategies/rsi.md +1 -1
  79. data/docs/strategy.md +381 -3
  80. data/docs/terms_of_use.md +1 -1
  81. data/examples/README.md +10 -0
  82. data/lib/api/alpha_vantage_api.rb +3 -7
  83. data/lib/sqa/config.rb +109 -28
  84. data/lib/sqa/data_frame/alpha_vantage.rb +13 -3
  85. data/lib/sqa/data_frame/data.rb +13 -1
  86. data/lib/sqa/data_frame.rb +189 -41
  87. data/lib/sqa/errors.rb +79 -17
  88. data/lib/sqa/indicator.rb +17 -4
  89. data/lib/sqa/init.rb +70 -15
  90. data/lib/sqa/pattern_matcher.rb +4 -4
  91. data/lib/sqa/portfolio.rb +1 -1
  92. data/lib/sqa/sector_analyzer.rb +3 -11
  93. data/lib/sqa/stock.rb +236 -20
  94. data/lib/sqa/strategy.rb +62 -4
  95. data/lib/sqa/ticker.rb +107 -42
  96. data/lib/sqa/version.rb +1 -1
  97. data/lib/sqa.rb +16 -8
  98. data/mkdocs.yml +68 -117
  99. metadata +90 -36
  100. data/docs/README.md +0 -43
  101. data/docs/alpha_vantage_technical_indicators.md +0 -62
  102. data/docs/average_true_range.md +0 -9
  103. data/docs/bollinger_bands.md +0 -15
  104. data/docs/candlestick_pattern_recognizer.md +0 -4
  105. data/docs/donchian_channel.md +0 -5
  106. data/docs/double_top_bottom_pattern.md +0 -3
  107. data/docs/exponential_moving_average.md +0 -19
  108. data/docs/fibonacci_retracement.md +0 -30
  109. data/docs/head_and_shoulders_pattern.md +0 -3
  110. data/docs/market_profile.md +0 -4
  111. data/docs/momentum.md +0 -19
  112. data/docs/moving_average_convergence_divergence.md +0 -23
  113. data/docs/peaks_and_valleys.md +0 -11
  114. data/docs/relative_strength_index.md +0 -6
  115. data/docs/simple_moving_average.md +0 -8
  116. data/docs/stochastic_oscillator.md +0 -4
  117. data/docs/ta_lib.md +0 -160
  118. data/docs/true_range.md +0 -12
  119. data/docs/true_strength_index.md +0 -46
  120. data/docs/weighted_moving_average.md +0 -48
  121. data/examples/sinatra_app/Gemfile +0 -22
  122. data/examples/sinatra_app/QUICKSTART.md +0 -159
  123. data/examples/sinatra_app/README.md +0 -461
  124. data/examples/sinatra_app/app.rb +0 -344
  125. data/examples/sinatra_app/config.ru +0 -5
  126. data/examples/sinatra_app/public/css/style.css +0 -659
  127. data/examples/sinatra_app/public/js/app.js +0 -107
  128. data/examples/sinatra_app/views/analyze.erb +0 -306
  129. data/examples/sinatra_app/views/backtest.erb +0 -325
  130. data/examples/sinatra_app/views/dashboard.erb +0 -419
  131. data/examples/sinatra_app/views/error.erb +0 -58
  132. data/examples/sinatra_app/views/index.erb +0 -118
  133. data/examples/sinatra_app/views/layout.erb +0 -61
  134. data/examples/sinatra_app/views/portfolio.erb +0 -43
@@ -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,53 +1,176 @@
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
13
84
 
14
- raise "Invalid Ticker #{ticker}" unless SQA::Ticker.valid?(ticker)
15
-
16
85
  @data_path = SQA.data_dir + "#{@ticker}.json"
17
86
  @df_path = SQA.data_dir + "#{@ticker}.csv"
18
87
 
88
+ # Validate ticker if validation data is available and cached data doesn't exist
89
+ unless @data_path.exist? && @df_path.exist?
90
+ unless SQA::Ticker.valid?(ticker)
91
+ warn "Warning: Ticker #{ticker} could not be validated. Proceeding anyway." if $VERBOSE
92
+ end
93
+ end
94
+
19
95
  @klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
20
96
  @transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
21
97
 
22
98
  load_or_create_data
23
- update_the_dataframe
99
+ update_dataframe
24
100
  end
25
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]
26
107
  def load_or_create_data
27
108
  if @data_path.exist?
28
109
  @data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
29
110
  else
111
+ # Create minimal data structure
30
112
  create_data
113
+
114
+ # Try to fetch overview data, but don't fail if we can't
115
+ # This is optional metadata - we can work with just price data
31
116
  update
117
+
118
+ # Save whatever data we have (even if overview fetch failed)
32
119
  save_data
33
120
  end
34
121
  end
35
122
 
123
+ # Creates a new minimal data structure for the stock.
124
+ #
125
+ # @return [SQA::DataFrame::Data] The newly created data object
36
126
  def create_data
37
- @data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: { xyzzy: "Magic" })
127
+ @data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {})
38
128
  end
39
129
 
130
+ # Updates the stock's overview data from the API.
131
+ # Silently handles errors since overview data is optional.
132
+ #
133
+ # @return [void]
40
134
  def update
41
- merge_overview
135
+ begin
136
+ merge_overview
137
+ rescue StandardError => e
138
+ # Log warning but don't fail - overview data is optional
139
+ # Common causes: rate limits, network issues, API errors
140
+ warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
141
+ end
42
142
  end
43
143
 
144
+ # Persists the stock's metadata to a JSON file.
145
+ #
146
+ # @return [Integer] Number of bytes written
44
147
  def save_data
45
148
  @data_path.write(@data.to_json)
46
149
  end
47
150
 
151
+ # @!method ticker
152
+ # @return [String] The stock's ticker symbol
153
+ # @!method name
154
+ # @return [String, nil] The company name
155
+ # @!method exchange
156
+ # @return [String, nil] The exchange where the stock trades
157
+ # @!method source
158
+ # @return [Symbol] The data source (:alpha_vantage or :yahoo_finance)
159
+ # @!method indicators
160
+ # @return [Hash] Cached indicator values
161
+ # @!method indicators=(value)
162
+ # @param value [Hash] New indicator values
163
+ # @!method overview
164
+ # @return [Hash, nil] Company overview data from API
48
165
  def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
49
166
 
50
- def update_the_dataframe
167
+ # Updates the DataFrame with price data.
168
+ # Loads from cache if available, otherwise fetches from API.
169
+ # Applies migrations for old data formats and updates with recent data.
170
+ #
171
+ # @return [void]
172
+ # @raise [SQA::DataFetchError] If data cannot be fetched and no cache exists
173
+ def update_dataframe
51
174
  if @df_path.exist?
52
175
  # Load cached CSV - transformers already applied when data was first fetched
53
176
  # Don't reapply them as columns are already in correct format
@@ -82,36 +205,111 @@ class SQA::Stock
82
205
  @df.to_csv(@df_path) if migrated
83
206
  else
84
207
  # Fetch fresh data from source (applies transformers and mapping)
85
- @df = @klass.recent(@ticker, full: true)
86
- @df.to_csv(@df_path)
87
- return
208
+ begin
209
+ @df = @klass.recent(@ticker, full: true)
210
+ @df.to_csv(@df_path)
211
+ return
212
+ rescue StandardError => e
213
+ # If we can't fetch data, raise a more helpful error
214
+ raise SQA::DataFetchError.new(
215
+ "Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}",
216
+ original: e
217
+ )
218
+ end
88
219
  end
89
220
 
90
221
  update_dataframe_with_recent_data
91
222
  end
92
223
 
224
+ # Fetches recent data from API and appends to existing DataFrame.
225
+ # Only called if should_update? returns true.
226
+ #
227
+ # @return [void]
93
228
  def update_dataframe_with_recent_data
94
- from_date = Date.parse(@df["timestamp"].to_a.last)
95
- df2 = @klass.recent(@ticker, from_date: from_date)
229
+ return unless should_update?
230
+
231
+ begin
232
+ # CSV is sorted ascending (oldest first, TA-Lib compatible), so .last gets the most recent date
233
+ from_date = Date.parse(@df["timestamp"].to_a.last)
234
+ df2 = @klass.recent(@ticker, from_date: from_date)
235
+
236
+ if df2 && (df2.size > 0)
237
+ # Use concat_and_deduplicate! to prevent duplicate timestamps and maintain ascending sort
238
+ @df.concat_and_deduplicate!(df2)
239
+ @df.to_csv(@df_path)
240
+ end
241
+ rescue StandardError => e
242
+ # Log warning but don't fail - we have cached data
243
+ # Common causes: rate limits, network issues, API errors
244
+ warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
245
+ end
246
+ end
247
+
248
+ # @deprecated Use {#update_dataframe} instead. Will be removed in v1.0.0
249
+ # @return [void]
250
+ def update_the_dataframe
251
+ warn "[SQA DEPRECATION] update_the_dataframe is deprecated; use update_dataframe instead" if $VERBOSE
252
+ update_dataframe
253
+ end
96
254
 
97
- if df2 && (df2.size > 0)
98
- @df.concat!(df2)
99
- @df.to_csv(@df_path)
255
+ # Determines whether the DataFrame should be updated from the API.
256
+ # Returns false if lazy_update is enabled, API key is missing,
257
+ # or data is already current.
258
+ #
259
+ # @return [Boolean] true if update should proceed, false otherwise
260
+ def should_update?
261
+ # Don't update if we're in lazy update mode
262
+ return false if SQA.config.lazy_update
263
+
264
+ # Don't update if we don't have an API key (only relevant for Alpha Vantage)
265
+ if @source == :alpha_vantage
266
+ begin
267
+ SQA.av_api_key
268
+ rescue SQA::ConfigurationError
269
+ return false
270
+ end
100
271
  end
272
+
273
+ # Don't update if CSV data is already current (last timestamp is today or later)
274
+ # This prevents unnecessary API calls when we already have today's data
275
+ if @df && @df.size > 0
276
+ begin
277
+ last_timestamp = Date.parse(@df["timestamp"].to_a.last)
278
+ return false if last_timestamp >= Date.today
279
+ rescue ArgumentError, Date::Error => e
280
+ # If we can't parse the date, assume we need to update
281
+ warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
282
+ end
283
+ end
284
+
285
+ true
101
286
  end
102
287
 
288
+ # Returns a human-readable string representation of the stock.
289
+ #
290
+ # @return [String] Summary including ticker, data points count, and date range
291
+ #
292
+ # @example
293
+ # stock.to_s # => "aapl with 252 data points from 2023-01-03 to 2023-12-29"
103
294
  def to_s
104
295
  "#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
105
296
  end
297
+ # Note: CSV data is stored in ascending chronological order (oldest to newest)
298
+ # This ensures compatibility with TA-Lib indicators which expect arrays in this order
106
299
  alias_method :inspect, :to_s
107
300
 
301
+ # Fetches and merges company overview data from Alpha Vantage API.
302
+ # Converts API response keys to snake_case and appropriate data types.
303
+ #
304
+ # @return [Hash] The merged overview data
305
+ # @raise [ApiError] If the API returns an error response
108
306
  def merge_overview
109
307
  temp = JSON.parse(
110
- CONNECTION.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
308
+ self.class.connection.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
111
309
  .to_hash[:body]
112
310
  )
113
311
 
114
- if temp.has_key? "Information"
312
+ if temp.key?("Information")
115
313
  ApiError.raise(temp["Information"])
116
314
  end
117
315
 
@@ -130,10 +328,20 @@ class SQA::Stock
130
328
  ## Class Methods
131
329
 
132
330
  class << self
331
+ # Fetches top gainers, losers, and most actively traded stocks from Alpha Vantage.
332
+ # Results are cached after the first call.
333
+ #
334
+ # @return [Hashie::Mash] Object with top_gainers, top_losers, and most_actively_traded arrays
335
+ #
336
+ # @example
337
+ # top = SQA::Stock.top
338
+ # top.top_gainers.each { |stock| puts "#{stock.ticker}: +#{stock.change_percentage}%" }
339
+ # top.top_losers.first.ticker # => "XYZ"
340
+ #
133
341
  def top
134
- return @@top unless @@top.nil?
342
+ return @top if @top
135
343
 
136
- a_hash = JSON.parse(CONNECTION.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
344
+ a_hash = JSON.parse(connection.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
137
345
 
138
346
  mash = Hashie::Mash.new(a_hash)
139
347
 
@@ -154,7 +362,15 @@ class SQA::Stock
154
362
  end
155
363
  end
156
364
 
157
- @@top = mash
365
+ @top = mash
366
+ end
367
+
368
+ # Resets the cached top gainers/losers data.
369
+ # Useful for testing or forcing a refresh.
370
+ #
371
+ # @return [nil]
372
+ def reset_top!
373
+ @top = nil
158
374
  end
159
375
  end
160
376
  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
data/lib/sqa/ticker.rb CHANGED
@@ -1,68 +1,133 @@
1
1
  # sqa/lib/sqa/ticker.rb
2
2
  #
3
- # Uses the https://dumbstockapi.com/ website to download a CSV file
3
+ # Stock ticker symbol validation and lookup using the dumbstockapi.com service.
4
+ # Downloads and caches a CSV file containing ticker symbols, company names, and exchanges.
4
5
  #
5
- # The CSV files have names like this:
6
- # "dumbstockapi-2023-09-21T16 39 55.165Z.csv"
6
+ # @example Validating a ticker
7
+ # SQA::Ticker.valid?('AAPL') # => true
8
+ # SQA::Ticker.valid?('FAKE') # => false
7
9
  #
8
- # which has this header:
9
- # ticker,name,is_etf,exchange
10
- #
11
- # Not using the is_etf columns
10
+ # @example Looking up ticker info
11
+ # info = SQA::Ticker.lookup('AAPL')
12
+ # info[:name] # => "Apple Inc"
13
+ # info[:exchange] # => "NASDAQ"
12
14
  #
13
15
  class SQA::Ticker
16
+ # @return [String] Prefix for downloaded CSV filenames
14
17
  FILENAME_PREFIX = "dumbstockapi"
18
+
19
+ # @return [Faraday::Connection] Connection to dumbstockapi.com
15
20
  CONNECTION = Faraday.new(url: "https://dumbstockapi.com")
16
- @@data = {}
17
21
 
22
+ class << self
23
+ # Downloads ticker data from dumbstockapi.com and saves to data directory.
24
+ #
25
+ # @param country [String] Country code for ticker list (default: "US")
26
+ # @return [Integer] HTTP status code from the download request
27
+ #
28
+ # @example
29
+ # SQA::Ticker.download("US") # => 200
30
+ #
31
+ def download(country="US")
32
+ response = CONNECTION.get("/stock?format=csv&countries=#{country.upcase}").to_hash
18
33
 
19
- def self.download(country="US")
20
- response = CONNECTION.get("/stock?format=csv&countries=#{country.upcase}").to_hash
34
+ if 200 == response[:status]
35
+ filename = response[:response_headers]["content-disposition"].split('=').last.gsub('"','')
36
+ out_path = Pathname.new(SQA.config.data_dir) + filename
37
+ out_path.write response[:body]
38
+ end
21
39
 
22
- if 200 == response[:status]
23
- filename = response[:response_headers]["content-disposition"].split('=').last.gsub('"','')
24
- out_path = Pathname.new(SQA.config.data_dir) + filename
25
- out_path.write response[:body]
40
+ response[:status]
26
41
  end
27
42
 
28
- response[:status]
29
- end
30
-
43
+ # Loads ticker data from cached CSV or downloads if not available.
44
+ # Retries download up to 3 times if no cached file exists.
45
+ #
46
+ # @return [Hash{String => Hash}] Hash mapping ticker symbols to info hashes
47
+ def load
48
+ tries = 0
49
+ found = false
31
50
 
32
- def self.load
33
- tries = 0
34
- found = false
51
+ until(found || tries >= 3) do
52
+ files = Pathname.new(SQA.config.data_dir).children.select{|c| c.basename.to_s.start_with?(FILENAME_PREFIX)}.sort
53
+ if files.empty?
54
+ begin
55
+ download
56
+ rescue StandardError => e
57
+ warn "Warning: Could not download ticker list: #{e.message}" if $VERBOSE
58
+ end
59
+ tries += 1
60
+ else
61
+ found = true
62
+ end
63
+ end
35
64
 
36
- until(found || tries >= 3) do
37
- files = Pathname.new(SQA.config.data_dir).children.select{|c| c.basename.to_s.start_with?(FILENAME_PREFIX)}.sort
38
65
  if files.empty?
39
- download
40
- tries += 1
41
- else
42
- found = true
66
+ warn "Warning: No ticker validation data available. Proceeding without validation." if $VERBOSE
67
+ return {}
43
68
  end
44
- end
45
69
 
46
- raise "NoDataError" if files.empty?
47
-
48
- load_from_csv files.last
49
- end
70
+ load_from_csv files.last
71
+ end
50
72
 
73
+ # Loads ticker data from a specific CSV file.
74
+ #
75
+ # @param csv_path [Pathname, String] Path to CSV file
76
+ # @return [Hash{String => Hash}] Hash mapping ticker symbols to info hashes
77
+ def load_from_csv(csv_path)
78
+ @data ||= {}
79
+ CSV.foreach(csv_path, headers: true) do |row|
80
+ @data[row["ticker"]] = {
81
+ name: row["name"],
82
+ exchange: row["exchange"]
83
+ }
84
+ end
51
85
 
52
- def self.load_from_csv(csv_path)
53
- CSV.foreach(csv_path, headers: true) do |row|
54
- @@data[row["ticker"]] = {
55
- name: row["name"],
56
- exchange: row["exchange"]
57
- }
86
+ @data
58
87
  end
59
88
 
60
- @@data
61
- end
89
+ # Returns the cached ticker data, loading it if necessary.
90
+ #
91
+ # @return [Hash{String => Hash}] Hash mapping ticker symbols to info hashes
92
+ def data
93
+ @data ||= {}
94
+ @data.empty? ? load : @data
95
+ end
62
96
 
97
+ # Looks up information for a specific ticker symbol.
98
+ #
99
+ # @param ticker [String, nil] Ticker symbol to look up
100
+ # @return [Hash, nil] Hash with :name and :exchange keys, or nil if not found
101
+ #
102
+ # @example
103
+ # SQA::Ticker.lookup('AAPL') # => { name: "Apple Inc", exchange: "NASDAQ" }
104
+ # SQA::Ticker.lookup('FAKE') # => nil
105
+ #
106
+ def lookup(ticker)
107
+ return nil if ticker.nil? || ticker.to_s.empty?
108
+ data[ticker.to_s.upcase]
109
+ end
63
110
 
111
+ # Checks if a ticker symbol is valid (exists in the data).
112
+ #
113
+ # @param ticker [String, nil] Ticker symbol to validate
114
+ # @return [Boolean] true if ticker exists, false otherwise
115
+ #
116
+ # @example
117
+ # SQA::Ticker.valid?('AAPL') # => true
118
+ # SQA::Ticker.valid?(nil) # => false
119
+ #
120
+ def valid?(ticker)
121
+ return false if ticker.nil? || ticker.to_s.empty?
122
+ data.key?(ticker.to_s.upcase)
123
+ end
64
124
 
65
- def self.data = @@data.empty? ? load : @@data
66
- def self.lookup(ticker) = data[ticker.upcase]
67
- def self.valid?(ticker) = data.has_key?(ticker.upcase)
125
+ # Resets the cached ticker data.
126
+ # Useful for testing to force a fresh load.
127
+ #
128
+ # @return [Hash] Empty hash
129
+ def reset!
130
+ @data = {}
131
+ end
132
+ end
68
133
  end
data/lib/sqa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SQA
4
- VERSION = '0.0.31'
4
+ VERSION = '0.0.37'
5
5
  end