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
@@ -21,9 +21,21 @@ class SQA::DataFrame
21
21
  # data = SQA::DataFrame::Data.new(json_data)
22
22
  #
23
23
  class Data
24
+ # @!attribute [rw] ticker
25
+ # @return [String, nil] Stock ticker symbol (e.g., 'AAPL', 'MSFT')
26
+ # @!attribute [rw] name
27
+ # @return [String, nil] Company name
28
+ # @!attribute [rw] exchange
29
+ # @return [String, nil] Exchange where stock trades (e.g., 'NASDAQ', 'NYSE')
30
+ # @!attribute [rw] source
31
+ # @return [Symbol] Data source (:alpha_vantage, :yahoo_finance)
32
+ # @!attribute [rw] indicators
33
+ # @return [Hash] Technical indicators configuration and cached values
34
+ # @!attribute [rw] overview
35
+ # @return [Hash] Company overview data from API
24
36
  attr_accessor :ticker, :name, :exchange, :source, :indicators, :overview
25
37
 
26
- # Initialize stock metadata
38
+ # Initializes stock metadata.
27
39
  #
28
40
  # Can be called in two ways:
29
41
  # 1. With a hash: SQA::DataFrame::Data.new(hash) - for JSON deserialization
@@ -10,11 +10,40 @@ require_relative 'data_frame/data'
10
10
  require_relative 'data_frame/yahoo_finance'
11
11
  require_relative 'data_frame/alpha_vantage'
12
12
 
13
+ # High-performance DataFrame wrapper around Polars for time series data manipulation.
14
+ # Provides convenience methods for stock market data while leveraging Polars' Rust-backed
15
+ # performance for vectorized operations.
16
+ #
17
+ # @example Creating from CSV
18
+ # df = SQA::DataFrame.load(source: "path/to/data.csv")
19
+ #
20
+ # @example Creating from array of hashes
21
+ # data = [{ timestamp: "2024-01-01", close: 100.0 }, { timestamp: "2024-01-02", close: 101.5 }]
22
+ # df = SQA::DataFrame.from_aofh(data)
23
+ #
24
+ # @example Accessing data
25
+ # prices = df["adj_close_price"].to_a
26
+ # df.columns # => ["timestamp", "open_price", "high_price", ...]
27
+ #
13
28
  class SQA::DataFrame
14
29
  extend Forwardable
15
30
 
31
+ # @!attribute [rw] data
32
+ # @return [Polars::DataFrame] The underlying Polars DataFrame
16
33
  attr_accessor :data
17
34
 
35
+ # Creates a new DataFrame instance.
36
+ #
37
+ # @param raw_data [Hash, Array, Polars::DataFrame, nil] Initial data for the DataFrame
38
+ # @param mapping [Hash] Column name mappings to apply (old_name => new_name)
39
+ # @param transformers [Hash] Column transformers to apply (column => lambda)
40
+ #
41
+ # @example With column mapping
42
+ # df = SQA::DataFrame.new(data, mapping: { "Close" => "close_price" })
43
+ #
44
+ # @example With transformers
45
+ # df = SQA::DataFrame.new(data, transformers: { "price" => ->(v) { v.to_f } })
46
+ #
18
47
  def initialize(raw_data = nil, mapping: {}, transformers: {})
19
48
  @data = Polars::DataFrame.new(raw_data || [])
20
49
 
@@ -25,6 +54,14 @@ class SQA::DataFrame
25
54
  end
26
55
 
27
56
 
57
+ # Applies transformer functions to specified columns in place.
58
+ #
59
+ # @param transformers [Hash{String, Symbol => Proc}] Column name to transformer mapping
60
+ # @return [void]
61
+ #
62
+ # @example
63
+ # df.apply_transformers!({ "price" => ->(v) { v.to_f }, "volume" => ->(v) { v.to_i } })
64
+ #
28
65
  def apply_transformers!(transformers)
29
66
  transformers.each do |col, transformer|
30
67
  col_name = col.to_s
@@ -34,7 +71,14 @@ class SQA::DataFrame
34
71
  end
35
72
  end
36
73
 
37
-
74
+ # Renames columns according to the provided mapping in place.
75
+ #
76
+ # @param mapping [Hash{String, Symbol => String}] Old column name to new column name mapping
77
+ # @return [void]
78
+ #
79
+ # @example
80
+ # df.rename_columns!({ "open" => "open_price", "close" => "close_price" })
81
+ #
38
82
  def rename_columns!(mapping)
39
83
  # Normalize mapping keys to strings for consistent lookup
40
84
  # mapping can have string or symbol keys, columns are always strings
@@ -51,6 +95,15 @@ class SQA::DataFrame
51
95
  end
52
96
 
53
97
 
98
+ # Appends another DataFrame to this one in place.
99
+ #
100
+ # @param other_df [SQA::DataFrame] DataFrame to append
101
+ # @return [void]
102
+ # @raise [RuntimeError] If the resulting row count doesn't match expected
103
+ #
104
+ # @example
105
+ # df1.append!(df2)
106
+ #
54
107
  def append!(other_df)
55
108
  self_row_count = @data.shape[0]
56
109
  other_row_count = other_df.data.shape[0]
@@ -76,7 +129,33 @@ class SQA::DataFrame
76
129
  # @param other_df [SQA::DataFrame] DataFrame to append
77
130
  # @param sort_column [String] Column to use for deduplication and sorting (default: "timestamp")
78
131
  # @param descending [Boolean] Sort order - false for ascending (oldest first, TA-Lib compatible), true for descending
132
+ #
133
+ # NOTE: TA-Lib requires data in ascending (oldest-first) order. Using descending: true
134
+ # will produce a warning and force ascending order to prevent silent calculation errors.
135
+ #
136
+ # @example Merge new data with deduplication
137
+ # stock = SQA::Stock.new(ticker: 'AAPL')
138
+ # df = stock.df
139
+ # df.size # => 252
140
+ #
141
+ # # Fetch recent data (may have overlapping dates)
142
+ # new_df = SQA::DataFrame::AlphaVantage.recent('AAPL', from_date: Date.today - 7)
143
+ # df.concat_and_deduplicate!(new_df)
144
+ # # Duplicates removed, data sorted ascending (oldest first)
145
+ # df.size # => 255 (only 3 new unique dates added)
146
+ #
147
+ # @example Maintains TA-Lib compatibility
148
+ # df.concat_and_deduplicate!(new_df) # Sorted ascending automatically
149
+ # prices = df["adj_close_price"].to_a
150
+ # rsi = SQAI.rsi(prices, period: 14) # Works correctly with ascending data
151
+ #
79
152
  def concat_and_deduplicate!(other_df, sort_column: "timestamp", descending: false)
153
+ # Enforce ascending order for TA-Lib compatibility
154
+ if descending
155
+ warn "[SQA WARNING] TA-Lib requires ascending (oldest-first) order. Forcing descending: false"
156
+ descending = false
157
+ end
158
+
80
159
  # Concatenate the dataframes
81
160
  @data = if @data.shape[0] == 0
82
161
  other_df.data
@@ -91,32 +170,61 @@ class SQA::DataFrame
91
170
  @data = @data.sort(sort_column, reverse: descending)
92
171
  end
93
172
 
173
+ # Returns the column names of the DataFrame.
174
+ #
175
+ # @return [Array<String>] List of column names
94
176
  def columns
95
177
  @data.columns
96
178
  end
97
179
 
98
-
180
+ # Returns the column names of the DataFrame.
181
+ # Alias for {#columns}.
182
+ #
183
+ # @return [Array<String>] List of column names
99
184
  def keys
100
185
  @data.columns
101
186
  end
102
187
  alias vectors keys
103
188
 
189
+ # Converts the DataFrame to a Ruby Hash.
190
+ #
191
+ # @return [Hash{Symbol => Array}] Hash with column names as keys and column data as arrays
192
+ #
193
+ # @example
194
+ # df.to_h # => { timestamp: ["2024-01-01", ...], close_price: [100.0, ...] }
195
+ #
104
196
  def to_h
105
197
  @data.columns.map { |col| [col.to_sym, @data[col].to_a] }.to_h
106
198
  end
107
199
 
108
-
200
+ # Writes the DataFrame to a CSV file.
201
+ #
202
+ # @param path_to_file [String, Pathname] Path to output CSV file
203
+ # @return [void]
204
+ #
205
+ # @example Save stock data to CSV
206
+ # stock = SQA::Stock.new(ticker: 'AAPL')
207
+ # stock.df.to_csv('aapl_prices.csv')
208
+ #
209
+ # @example Export with custom path
210
+ # df.to_csv(Pathname.new('data/exports/prices.csv'))
211
+ #
109
212
  def to_csv(path_to_file)
110
213
  @data.write_csv(path_to_file)
111
214
  end
112
215
 
113
-
216
+ # Returns the number of rows in the DataFrame.
217
+ #
218
+ # @return [Integer] Row count
114
219
  def size
115
220
  @data.height
116
221
  end
117
222
  alias nrows size
118
223
  alias length size
119
224
 
225
+ # Returns the number of columns in the DataFrame.
226
+ #
227
+ # @return [Integer] Column count
120
228
  def ncols
121
229
  @data.width
122
230
  end
@@ -156,23 +264,31 @@ class SQA::DataFrame
156
264
  end
157
265
 
158
266
 
267
+ # Checks if a value appears to be a date string.
268
+ #
269
+ # @param value [Object] Value to check
270
+ # @return [Boolean] true if value matches YYYY-MM-DD format
159
271
  def self.is_date?(value)
160
272
  value.is_a?(String) && !/\d{4}-\d{2}-\d{2}/.match(value).nil?
161
273
  end
162
274
 
163
-
275
+ # Delegates unknown methods to the underlying Polars DataFrame.
276
+ # This allows direct access to Polars methods like filter, select, etc.
277
+ #
278
+ # @param method_name [Symbol] Method name being called
279
+ # @param args [Array] Method arguments
280
+ # @param block [Proc] Optional block
281
+ # @return [Object] Result from Polars DataFrame method
164
282
  def method_missing(method_name, *args, &block)
165
- if @data.respond_to?(method_name)
166
- self.class.send(:define_method, method_name) do |*method_args, &method_block|
167
- @data.send(method_name, *method_args, &method_block)
168
- end
169
- send(method_name, *args, &block)
170
- else
171
- super
172
- end
283
+ return super unless @data.respond_to?(method_name)
284
+ @data.send(method_name, *args, &block)
173
285
  end
174
286
 
175
-
287
+ # Checks if the DataFrame responds to a method.
288
+ #
289
+ # @param method_name [Symbol] Method name to check
290
+ # @param include_private [Boolean] Include private methods
291
+ # @return [Boolean] true if method is available
176
292
  def respond_to_missing?(method_name, include_private = false)
177
293
  @data.respond_to?(method_name) || super
178
294
  end
@@ -197,37 +313,74 @@ class SQA::DataFrame
197
313
  new(df, mapping: mapping, transformers: transformers)
198
314
  end
199
315
 
316
+ # Creates a DataFrame from an array of hashes.
317
+ #
318
+ # @param aofh [Array<Hash>] Array of hash records
319
+ # @param mapping [Hash] Column name mappings to apply
320
+ # @param transformers [Hash] Column transformers to apply
321
+ # @return [SQA::DataFrame] New DataFrame instance
322
+ #
323
+ # @example
324
+ # data = [{ "date" => "2024-01-01", "price" => 100.0 }]
325
+ # df = SQA::DataFrame.from_aofh(data)
326
+ #
200
327
  def from_aofh(aofh, mapping: {}, transformers: {})
328
+ return new({}, mapping: mapping, transformers: transformers) if aofh.empty?
329
+
330
+ # Sanitize keys to strings and convert to hash of arrays (Polars-compatible format)
201
331
  aoh_sanitized = aofh.map { |entry| entry.transform_keys(&:to_s) }
202
332
  columns = aoh_sanitized.first.keys
203
- data = aoh_sanitized.map(&:values)
204
- df = Polars::DataFrame.new(
205
- data,
206
- columns: columns
207
- )
208
- new(df)
209
- end
210
333
 
334
+ # Convert array-of-hashes to hash-of-arrays for Polars
335
+ hofa = columns.each_with_object({}) do |col, hash|
336
+ hash[col] = aoh_sanitized.map { |row| row[col] }
337
+ end
211
338
 
339
+ df = Polars::DataFrame.new(hofa)
340
+ new(df, mapping: mapping, transformers: transformers)
341
+ end
342
+
343
+ # Creates a DataFrame from a CSV file.
344
+ #
345
+ # @param source [String, Pathname] Path to CSV file
346
+ # @param mapping [Hash] Column name mappings to apply
347
+ # @param transformers [Hash] Column transformers to apply
348
+ # @return [SQA::DataFrame] New DataFrame instance
212
349
  def from_csv_file(source, mapping: {}, transformers: {})
213
350
  df = Polars.read_csv(source)
214
351
  new(df, mapping: mapping, transformers: transformers)
215
352
  end
216
353
 
217
-
354
+ # Creates a DataFrame from a JSON file.
355
+ #
356
+ # @param source [String, Pathname] Path to JSON file containing array of objects
357
+ # @param mapping [Hash] Column name mappings to apply
358
+ # @param transformers [Hash] Column transformers to apply
359
+ # @return [SQA::DataFrame] New DataFrame instance
218
360
  def from_json_file(source, mapping: {}, transformers: {})
219
361
  aofh = JSON.parse(File.read(source)).map { |entry| entry.transform_keys(&:to_s) }
220
362
  from_aofh(aofh, mapping: mapping, transformers: transformers)
221
363
  end
222
364
 
223
-
365
+ # Generates a mapping of original keys to underscored keys.
366
+ #
367
+ # @param keys [Array<String>] Original key names
368
+ # @return [Hash{String => Symbol}] Mapping from original to underscored keys
224
369
  def generate_mapping(keys)
225
370
  keys.each_with_object({}) do |key, hash|
226
371
  hash[key.to_s] = underscore_key(key.to_s)
227
372
  end
228
373
  end
229
374
 
230
-
375
+ # Converts a key string to underscored snake_case format.
376
+ #
377
+ # @param key [String] Key to convert
378
+ # @return [Symbol] Underscored key as symbol
379
+ #
380
+ # @example
381
+ # underscore_key("closePrice") # => :close_price
382
+ # underscore_key("Close Price") # => :close_price
383
+ #
231
384
  def underscore_key(key)
232
385
  key.to_s
233
386
  .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
@@ -242,19 +395,33 @@ class SQA::DataFrame
242
395
 
243
396
  alias sanitize_key underscore_key
244
397
 
398
+ # Normalizes all keys in a hash to snake_case format.
399
+ #
400
+ # @param hash [Hash] Hash with keys to normalize
401
+ # @param adapter_mapping [Hash] Optional pre-mapping to apply first
402
+ # @return [Hash] Hash with normalized keys
245
403
  def normalize_keys(hash, adapter_mapping: {})
246
404
  hash = rename(hash, adapter_mapping) unless adapter_mapping.empty?
247
405
  mapping = generate_mapping(hash.keys)
248
406
  rename(hash, mapping)
249
407
  end
250
408
 
251
-
409
+ # Renames keys in a hash according to a mapping.
410
+ #
411
+ # @param hash [Hash] Hash to modify
412
+ # @param mapping [Hash] Old key to new key mapping
413
+ # @return [Hash] Modified hash
252
414
  def rename(hash, mapping)
253
415
  mapping.each { |old_key, new_key| hash[new_key] = hash.delete(old_key) if hash.key?(old_key) }
254
416
  hash
255
417
  end
256
418
 
257
-
419
+ # Converts array of hashes to hash of arrays format.
420
+ #
421
+ # @param aofh [Array<Hash>] Array of hash records
422
+ # @param mapping [Hash] Column name mappings (unused, for API compatibility)
423
+ # @param transformers [Hash] Column transformers (unused, for API compatibility)
424
+ # @return [Hash{String => Array}] Hash with column names as keys and arrays as values
258
425
  def aofh_to_hofa(aofh, mapping: {}, transformers: {})
259
426
  hofa = Hash.new { |h, k| h[k.downcase] = [] }
260
427
  aofh.each { |entry| entry.each { |key, value| hofa[key.to_s.downcase] << value } }
data/lib/sqa/errors.rb CHANGED
@@ -1,30 +1,92 @@
1
1
  # lib/sqa/errors.rb
2
+ #
3
+ # SQA Exception Classes
4
+ # All custom exceptions inherit from StandardError or RuntimeError
5
+ # to ensure they can be caught by generic rescue blocks.
2
6
 
3
- # raised when a method is still in TODO state
7
+ module SQA
8
+ # Raised when unable to fetch data from a data source (API, file, etc.).
9
+ # Wraps the original exception for debugging purposes.
10
+ #
11
+ # @example Raising with original error
12
+ # begin
13
+ # response = api.fetch(ticker)
14
+ # rescue Faraday::Error => e
15
+ # raise SQA::DataFetchError.new("Failed to fetch #{ticker}", original: e)
16
+ # end
17
+ #
18
+ # @example Accessing original error
19
+ # begin
20
+ # stock = SQA::Stock.new(ticker: 'INVALID')
21
+ # rescue SQA::DataFetchError => e
22
+ # puts e.message
23
+ # puts e.original_error.class if e.original_error
24
+ # end
25
+ #
26
+ class DataFetchError < StandardError
27
+ # @return [Exception, nil] The original exception that caused this error
28
+ attr_reader :original_error
29
+
30
+ # Creates a new DataFetchError.
31
+ #
32
+ # @param message [String] Error message describing the fetch failure
33
+ # @param original [Exception, nil] The original exception that was caught
34
+ def initialize(message, original: nil)
35
+ @original_error = original
36
+ super(message)
37
+ end
38
+ end
39
+
40
+ # Raised when SQA configuration is invalid or missing.
41
+ # Common causes include missing API keys or invalid data directories.
42
+ #
43
+ # @example
44
+ # raise SQA::ConfigurationError, "API key not set"
45
+ #
46
+ class ConfigurationError < StandardError; end
47
+
48
+ # Raised when a method parameter is invalid.
49
+ # Inherits from ArgumentError for semantic clarity.
50
+ #
51
+ # @example
52
+ # raise SQA::BadParameterError, "Expected a Class or Method"
53
+ #
54
+ class BadParameterError < ArgumentError; end
55
+ end
56
+
57
+ # Global alias for backward compatibility.
58
+ # @deprecated Use SQA::BadParameterError instead
59
+ BadParameterError = SQA::BadParameterError
60
+
61
+ # Raised when an external API returns an error response.
62
+ # Automatically logs the error using debug_me before raising.
63
+ #
64
+ # @example
65
+ # ApiError.raise("Rate limit exceeded")
66
+ #
4
67
  class ApiError < RuntimeError
68
+ # Raises an ApiError with debug logging.
69
+ #
70
+ # @param why [String] The error message from the API
71
+ # @raise [ApiError] Always raises after logging
5
72
  def self.raise(why)
6
- puts "="*64
7
- puts "== API Error"
8
- puts why
9
- puts
10
- puts "Callback trace:"
11
- puts caller
12
- puts "="*64
73
+ debug_me {"API Error: #{why}"}
13
74
  super
14
75
  end
15
76
  end
16
77
 
17
- # raised when a method is still in TODO state
78
+ # Raised when a feature is not yet implemented.
79
+ # Automatically logs using debug_me before raising.
80
+ #
81
+ # @example
82
+ # NotImplemented.raise
83
+ #
18
84
  class NotImplemented < RuntimeError
85
+ # Raises a NotImplemented error with debug logging.
86
+ #
87
+ # @raise [NotImplemented] Always raises after logging
19
88
  def self.raise
20
- puts "="*64
21
- puts "== Not Yet Implemented"
22
- puts "Callback trace:"
23
- puts caller
24
- puts "="*64
89
+ debug_me {"Not Yet Implemented"}
25
90
  super
26
91
  end
27
92
  end
28
-
29
- # raised when an API contract is broken
30
- class BadParameterError < ArgumentError; end
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?)