sqa 0.0.32 → 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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/README.md +4 -0
  4. data/Rakefile +52 -10
  5. data/docs/IMPROVEMENT_PLAN.md +531 -0
  6. data/docs/advanced/index.md +1 -13
  7. data/docs/api/index.md +547 -61
  8. data/docs/api-reference/alphavantageapi.md +1057 -0
  9. data/docs/api-reference/apierror.md +31 -0
  10. data/docs/api-reference/index.md +221 -0
  11. data/docs/api-reference/notimplemented.md +27 -0
  12. data/docs/api-reference/sqa.md +267 -0
  13. data/docs/api-reference/sqa_backtest.md +137 -0
  14. data/docs/api-reference/sqa_backtest_results.md +530 -0
  15. data/docs/api-reference/sqa_badparametererror.md +13 -0
  16. data/docs/api-reference/sqa_config.md +538 -0
  17. data/docs/api-reference/sqa_configurationerror.md +13 -0
  18. data/docs/api-reference/sqa_datafetcherror.md +56 -0
  19. data/docs/api-reference/sqa_dataframe.md +752 -0
  20. data/docs/api-reference/sqa_dataframe_alphavantage.md +30 -0
  21. data/docs/api-reference/sqa_dataframe_data.md +325 -0
  22. data/docs/api-reference/sqa_dataframe_yahoofinance.md +25 -0
  23. data/docs/api-reference/sqa_ensemble.md +413 -0
  24. data/docs/api-reference/sqa_fpop.md +211 -0
  25. data/docs/api-reference/sqa_geneticprogram.md +325 -0
  26. data/docs/api-reference/sqa_geneticprogram_individual.md +114 -0
  27. data/docs/api-reference/sqa_marketregime.md +212 -0
  28. data/docs/api-reference/sqa_multitimeframe.md +227 -0
  29. data/docs/api-reference/sqa_patternmatcher.md +195 -0
  30. data/docs/api-reference/sqa_pluginmanager.md +55 -0
  31. data/docs/api-reference/sqa_portfolio.md +455 -0
  32. data/docs/api-reference/sqa_portfolio_position.md +220 -0
  33. data/docs/api-reference/sqa_portfolio_trade.md +332 -0
  34. data/docs/api-reference/sqa_portfoliooptimizer.md +248 -0
  35. data/docs/api-reference/sqa_riskmanager.md +388 -0
  36. data/docs/api-reference/sqa_seasonalanalyzer.md +121 -0
  37. data/docs/api-reference/sqa_sectoranalyzer.md +163 -0
  38. data/docs/api-reference/sqa_stock.md +649 -0
  39. data/docs/api-reference/sqa_strategy.md +178 -0
  40. data/docs/api-reference/sqa_strategy_bollingerbands.md +26 -0
  41. data/docs/api-reference/sqa_strategy_common.md +29 -0
  42. data/docs/api-reference/sqa_strategy_consensus.md +129 -0
  43. data/docs/api-reference/sqa_strategy_ema.md +41 -0
  44. data/docs/api-reference/sqa_strategy_kbs.md +154 -0
  45. data/docs/api-reference/sqa_strategy_macd.md +26 -0
  46. data/docs/api-reference/sqa_strategy_mp.md +41 -0
  47. data/docs/api-reference/sqa_strategy_mr.md +41 -0
  48. data/docs/api-reference/sqa_strategy_random.md +41 -0
  49. data/docs/api-reference/sqa_strategy_rsi.md +41 -0
  50. data/docs/api-reference/sqa_strategy_sma.md +41 -0
  51. data/docs/api-reference/sqa_strategy_stochastic.md +26 -0
  52. data/docs/api-reference/sqa_strategy_volumebreakout.md +26 -0
  53. data/docs/api-reference/sqa_strategygenerator.md +298 -0
  54. data/docs/api-reference/sqa_strategygenerator_pattern.md +264 -0
  55. data/docs/api-reference/sqa_strategygenerator_patterncontext.md +326 -0
  56. data/docs/api-reference/sqa_strategygenerator_profitablepoint.md +424 -0
  57. data/docs/api-reference/sqa_stream.md +256 -0
  58. data/docs/api-reference/sqa_ticker.md +175 -0
  59. data/docs/api-reference/string.md +135 -0
  60. data/docs/assets/images/advanced-workflow.svg +89 -0
  61. data/docs/assets/images/architecture.svg +107 -0
  62. data/docs/assets/images/data-flow.svg +138 -0
  63. data/docs/assets/images/getting-started-workflow.svg +88 -0
  64. data/docs/assets/images/strategy-flow.svg +78 -0
  65. data/docs/assets/images/system-architecture.svg +150 -0
  66. data/docs/concepts/index.md +292 -19
  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/config.rb +109 -28
  76. data/lib/sqa/data_frame/data.rb +13 -1
  77. data/lib/sqa/data_frame.rb +168 -26
  78. data/lib/sqa/errors.rb +79 -17
  79. data/lib/sqa/init.rb +70 -15
  80. data/lib/sqa/pattern_matcher.rb +4 -4
  81. data/lib/sqa/portfolio.rb +1 -1
  82. data/lib/sqa/sector_analyzer.rb +3 -11
  83. data/lib/sqa/stock.rb +169 -15
  84. data/lib/sqa/strategy.rb +62 -4
  85. data/lib/sqa/ticker.rb +106 -48
  86. data/lib/sqa/version.rb +1 -1
  87. data/lib/sqa.rb +4 -4
  88. data/mkdocs.yml +68 -81
  89. metadata +89 -21
  90. data/docs/README.md +0 -43
  91. data/examples/sinatra_app/Gemfile +0 -42
  92. data/examples/sinatra_app/Gemfile.lock +0 -268
  93. data/examples/sinatra_app/QUICKSTART.md +0 -169
  94. data/examples/sinatra_app/README.md +0 -471
  95. data/examples/sinatra_app/RUNNING_WITHOUT_TALIB.md +0 -90
  96. data/examples/sinatra_app/TROUBLESHOOTING.md +0 -95
  97. data/examples/sinatra_app/app.rb +0 -404
  98. data/examples/sinatra_app/config.ru +0 -5
  99. data/examples/sinatra_app/public/css/style.css +0 -723
  100. data/examples/sinatra_app/public/debug_macd.html +0 -82
  101. data/examples/sinatra_app/public/js/app.js +0 -107
  102. data/examples/sinatra_app/start.sh +0 -53
  103. data/examples/sinatra_app/views/analyze.erb +0 -306
  104. data/examples/sinatra_app/views/backtest.erb +0 -325
  105. data/examples/sinatra_app/views/dashboard.erb +0 -831
  106. data/examples/sinatra_app/views/error.erb +0 -58
  107. data/examples/sinatra_app/views/index.erb +0 -118
  108. data/examples/sinatra_app/views/layout.erb +0 -61
  109. data/examples/sinatra_app/views/portfolio.erb +0 -43
data/lib/sqa/init.rb CHANGED
@@ -1,12 +1,35 @@
1
1
  # sqa/lib/sqa/init.rb
2
2
 
3
+ # SQA (Simple Qualitative Analysis) - Ruby library for stock market technical analysis.
4
+ # Provides high-performance data structures, trading strategies, and integrates with
5
+ # the sqa-tai gem for 150+ technical indicators.
6
+ #
7
+ # @example Basic usage
8
+ # require 'sqa'
9
+ # SQA.init
10
+ # stock = SQA::Stock.new(ticker: 'AAPL')
11
+ # prices = stock.df["adj_close_price"].to_a
12
+ #
13
+ # @example With configuration
14
+ # SQA.init
15
+ # SQA.config.data_dir = "~/my_data"
16
+ # SQA.config.lazy_update = true
17
+ #
3
18
  module SQA
4
19
  class << self
5
- @@config = nil
6
- @@av_api_key = ENV['AV_API_KEY'] || ENV['ALPHAVANTAGE_API_KEY']
20
+ # @!attribute [w] config
21
+ # @return [SQA::Config] Configuration instance
22
+ attr_writer :config
7
23
 
8
- # Initializes the SQA modules
9
- # returns the configuration
24
+ # Initializes the SQA library.
25
+ # Should be called once at application startup.
26
+ #
27
+ # @param argv [Array<String>, String] Command line arguments (default: ARGV)
28
+ # @return [SQA::Config] The configuration instance
29
+ #
30
+ # @example
31
+ # SQA.init
32
+ # SQA.init("--debug --verbose")
10
33
  #
11
34
  def init(argv=ARGV)
12
35
  if argv.is_a? String
@@ -15,10 +38,10 @@ module SQA
15
38
 
16
39
 
17
40
  # Ran at SQA::Config elaboration time
18
- # @@config = Config.new
41
+ # @config = Config.new
19
42
 
20
43
  if defined? CLI
21
- CLI.run! # TODO: how to parse a fake argv? (argv)
44
+ CLI.run! # CLI handles its own argument parsing
22
45
  else
23
46
  # There are no real command line parameters
24
47
  # because the sqa gem is being required within
@@ -30,29 +53,61 @@ module SQA
30
53
  config
31
54
  end
32
55
 
56
+ # Returns the Alpha Vantage API key.
57
+ # Reads from AV_API_KEY or ALPHAVANTAGE_API_KEY environment variables.
58
+ #
59
+ # @return [String] The API key
60
+ # @raise [SQA::ConfigurationError] If no API key is set
33
61
  def av_api_key
34
- @@av_api_key || raise('Alpha Vantage API key not set. Set AV_API_KEY or ALPHAVANTAGE_API_KEY environment variable.')
62
+ @av_api_key ||= ENV['AV_API_KEY'] || ENV['ALPHAVANTAGE_API_KEY']
63
+ @av_api_key || raise(SQA::ConfigurationError, 'Alpha Vantage API key not set. Set AV_API_KEY or ALPHAVANTAGE_API_KEY environment variable.')
35
64
  end
36
65
 
37
- # Legacy accessor for backward compatibility
66
+ # Sets the Alpha Vantage API key.
67
+ #
68
+ # @param key [String] The API key to set
69
+ # @return [String] The key that was set
70
+ def av_api_key=(key)
71
+ @av_api_key = key
72
+ end
73
+
74
+ # Legacy accessor for backward compatibility with SQA.av.key usage.
75
+ #
76
+ # @return [SQA] Self, to allow SQA.av.key calls
38
77
  def av
39
78
  self
40
79
  end
41
80
 
42
- # For compatibility with old SQA.av.key usage
81
+ # Returns the API key for compatibility with old SQA.av.key usage.
82
+ #
83
+ # @return [String] The API key
84
+ # @raise [SQA::ConfigurationError] If no API key is set
43
85
  def key
44
86
  av_api_key
45
87
  end
46
88
 
47
- def debug?() = @@config.debug?
48
- def verbose?() = @@config.verbose?
89
+ # Returns whether debug mode is enabled.
90
+ # @return [Boolean] true if debug mode is on
91
+ def debug?() = @config&.debug?
92
+
93
+ # Returns whether verbose mode is enabled.
94
+ # @return [Boolean] true if verbose mode is on
95
+ def verbose?() = @config&.verbose?
49
96
 
97
+ # Expands ~ to user's home directory in filepath.
98
+ #
99
+ # @param filepath [String] Path potentially containing ~
100
+ # @return [String] Path with ~ expanded
50
101
  def homify(filepath) = filepath.gsub(/^~/, Nenv.home)
102
+
103
+ # Returns the data directory as a Pathname.
104
+ #
105
+ # @return [Pathname] Data directory path
51
106
  def data_dir() = Pathname.new(config.data_dir)
52
- def config() = @@config
53
107
 
54
- def config=(an_object)
55
- @@config = an_object
56
- end
108
+ # Returns the current configuration.
109
+ #
110
+ # @return [SQA::Config] Configuration instance
111
+ def config() = @config
57
112
  end
58
113
  end
@@ -214,13 +214,13 @@ module SQA
214
214
  def pattern_quality(pattern)
215
215
  return nil if pattern.size < 3
216
216
 
217
- # Trend strength
218
- first = pattern.first
219
- last = pattern.last
217
+ # Trend strength (convert to float to avoid integer division)
218
+ first = pattern.first.to_f
219
+ last = pattern.last.to_f
220
220
  trend = (last - first) / first
221
221
 
222
222
  # Volatility
223
- returns = pattern.each_cons(2).map { |a, b| (b - a) / a }
223
+ returns = pattern.each_cons(2).map { |a, b| (b - a).to_f / a }
224
224
  volatility = standard_deviation(returns)
225
225
 
226
226
  # Smoothness (how linear is the trend?)
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
@@ -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,57 @@ 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]
51
134
  def update
52
135
  begin
53
136
  merge_overview
54
- rescue => e
137
+ rescue StandardError => e
55
138
  # Log warning but don't fail - overview data is optional
56
139
  # Common causes: rate limits, network issues, API errors
57
140
  warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
58
141
  end
59
142
  end
60
143
 
144
+ # Persists the stock's metadata to a JSON file.
145
+ #
146
+ # @return [Integer] Number of bytes written
61
147
  def save_data
62
148
  @data_path.write(@data.to_json)
63
149
  end
64
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
65
165
  def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
66
166
 
67
- 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
68
174
  if @df_path.exist?
69
175
  # Load cached CSV - transformers already applied when data was first fetched
70
176
  # Don't reapply them as columns are already in correct format
@@ -103,15 +209,22 @@ class SQA::Stock
103
209
  @df = @klass.recent(@ticker, full: true)
104
210
  @df.to_csv(@df_path)
105
211
  return
106
- rescue => e
212
+ rescue StandardError => e
107
213
  # 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}"
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
+ )
109
218
  end
110
219
  end
111
220
 
112
221
  update_dataframe_with_recent_data
113
222
  end
114
223
 
224
+ # Fetches recent data from API and appends to existing DataFrame.
225
+ # Only called if should_update? returns true.
226
+ #
227
+ # @return [void]
115
228
  def update_dataframe_with_recent_data
116
229
  return unless should_update?
117
230
 
@@ -125,13 +238,25 @@ class SQA::Stock
125
238
  @df.concat_and_deduplicate!(df2)
126
239
  @df.to_csv(@df_path)
127
240
  end
128
- rescue => e
241
+ rescue StandardError => e
129
242
  # Log warning but don't fail - we have cached data
130
243
  # Common causes: rate limits, network issues, API errors
131
244
  warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
132
245
  end
133
246
  end
134
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
254
+
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
135
260
  def should_update?
136
261
  # Don't update if we're in lazy update mode
137
262
  return false if SQA.config.lazy_update
@@ -140,7 +265,7 @@ class SQA::Stock
140
265
  if @source == :alpha_vantage
141
266
  begin
142
267
  SQA.av_api_key
143
- rescue
268
+ rescue SQA::ConfigurationError
144
269
  return false
145
270
  end
146
271
  end
@@ -151,7 +276,7 @@ class SQA::Stock
151
276
  begin
152
277
  last_timestamp = Date.parse(@df["timestamp"].to_a.last)
153
278
  return false if last_timestamp >= Date.today
154
- rescue => e
279
+ rescue ArgumentError, Date::Error => e
155
280
  # If we can't parse the date, assume we need to update
156
281
  warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
157
282
  end
@@ -160,6 +285,12 @@ class SQA::Stock
160
285
  true
161
286
  end
162
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"
163
294
  def to_s
164
295
  "#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
165
296
  end
@@ -167,13 +298,18 @@ class SQA::Stock
167
298
  # This ensures compatibility with TA-Lib indicators which expect arrays in this order
168
299
  alias_method :inspect, :to_s
169
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
170
306
  def merge_overview
171
307
  temp = JSON.parse(
172
- 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}")
173
309
  .to_hash[:body]
174
310
  )
175
311
 
176
- if temp.has_key? "Information"
312
+ if temp.key?("Information")
177
313
  ApiError.raise(temp["Information"])
178
314
  end
179
315
 
@@ -192,10 +328,20 @@ class SQA::Stock
192
328
  ## Class Methods
193
329
 
194
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
+ #
195
341
  def top
196
- return @@top unless @@top.nil?
342
+ return @top if @top
197
343
 
198
- 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])
199
345
 
200
346
  mash = Hashie::Mash.new(a_hash)
201
347
 
@@ -216,7 +362,15 @@ class SQA::Stock
216
362
  end
217
363
  end
218
364
 
219
- @@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
220
374
  end
221
375
  end
222
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