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
@@ -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,21 +10,43 @@ 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
 
21
- debug_me{[
22
- :raw_data,
23
- :mapping,
24
- :transformers,
25
- '@data'
26
- ]}
27
-
28
50
  # IMPORTANT: Rename columns FIRST, then apply transformers
29
51
  # Transformers expect renamed column names
30
52
  rename_columns!(mapping) unless mapping.empty?
@@ -32,6 +54,14 @@ class SQA::DataFrame
32
54
  end
33
55
 
34
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
+ #
35
65
  def apply_transformers!(transformers)
36
66
  transformers.each do |col, transformer|
37
67
  col_name = col.to_s
@@ -41,7 +71,14 @@ class SQA::DataFrame
41
71
  end
42
72
  end
43
73
 
44
-
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
+ #
45
82
  def rename_columns!(mapping)
46
83
  # Normalize mapping keys to strings for consistent lookup
47
84
  # mapping can have string or symbol keys, columns are always strings
@@ -58,6 +95,15 @@ class SQA::DataFrame
58
95
  end
59
96
 
60
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
+ #
61
107
  def append!(other_df)
62
108
  self_row_count = @data.shape[0]
63
109
  other_row_count = other_df.data.shape[0]
@@ -77,32 +123,83 @@ class SQA::DataFrame
77
123
  end
78
124
  alias concat! append!
79
125
 
126
+ # Concatenate another DataFrame, remove duplicates, and sort
127
+ # This is the preferred method for updating CSV data to prevent duplicates
128
+ #
129
+ # @param other_df [SQA::DataFrame] DataFrame to append
130
+ # @param sort_column [String] Column to use for deduplication and sorting (default: "timestamp")
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
+ def concat_and_deduplicate!(other_df, sort_column: "timestamp", descending: false)
136
+ # Enforce ascending order for TA-Lib compatibility
137
+ if descending
138
+ warn "[SQA WARNING] TA-Lib requires ascending (oldest-first) order. Forcing descending: false"
139
+ descending = false
140
+ end
141
+
142
+ # Concatenate the dataframes
143
+ @data = if @data.shape[0] == 0
144
+ other_df.data
145
+ else
146
+ @data.vstack(other_df.data)
147
+ end
148
+
149
+ # Remove duplicates based on sort_column, keeping first occurrence
150
+ @data = @data.unique(subset: [sort_column], keep: "first")
151
+
152
+ # Sort by the specified column (Polars uses 'reverse' for descending)
153
+ @data = @data.sort(sort_column, reverse: descending)
154
+ end
155
+
156
+ # Returns the column names of the DataFrame.
157
+ #
158
+ # @return [Array<String>] List of column names
80
159
  def columns
81
160
  @data.columns
82
161
  end
83
162
 
84
-
163
+ # Returns the column names of the DataFrame.
164
+ # Alias for {#columns}.
165
+ #
166
+ # @return [Array<String>] List of column names
85
167
  def keys
86
168
  @data.columns
87
169
  end
88
170
  alias vectors keys
89
171
 
172
+ # Converts the DataFrame to a Ruby Hash.
173
+ #
174
+ # @return [Hash{Symbol => Array}] Hash with column names as keys and column data as arrays
175
+ #
176
+ # @example
177
+ # df.to_h # => { timestamp: ["2024-01-01", ...], close_price: [100.0, ...] }
178
+ #
90
179
  def to_h
91
180
  @data.columns.map { |col| [col.to_sym, @data[col].to_a] }.to_h
92
181
  end
93
182
 
94
-
183
+ # Writes the DataFrame to a CSV file.
184
+ #
185
+ # @param path_to_file [String, Pathname] Path to output CSV file
186
+ # @return [void]
95
187
  def to_csv(path_to_file)
96
188
  @data.write_csv(path_to_file)
97
189
  end
98
190
 
99
-
191
+ # Returns the number of rows in the DataFrame.
192
+ #
193
+ # @return [Integer] Row count
100
194
  def size
101
195
  @data.height
102
196
  end
103
197
  alias nrows size
104
198
  alias length size
105
199
 
200
+ # Returns the number of columns in the DataFrame.
201
+ #
202
+ # @return [Integer] Column count
106
203
  def ncols
107
204
  @data.width
108
205
  end
@@ -142,23 +239,31 @@ class SQA::DataFrame
142
239
  end
143
240
 
144
241
 
242
+ # Checks if a value appears to be a date string.
243
+ #
244
+ # @param value [Object] Value to check
245
+ # @return [Boolean] true if value matches YYYY-MM-DD format
145
246
  def self.is_date?(value)
146
247
  value.is_a?(String) && !/\d{4}-\d{2}-\d{2}/.match(value).nil?
147
248
  end
148
249
 
149
-
250
+ # Delegates unknown methods to the underlying Polars DataFrame.
251
+ # This allows direct access to Polars methods like filter, select, etc.
252
+ #
253
+ # @param method_name [Symbol] Method name being called
254
+ # @param args [Array] Method arguments
255
+ # @param block [Proc] Optional block
256
+ # @return [Object] Result from Polars DataFrame method
150
257
  def method_missing(method_name, *args, &block)
151
- if @data.respond_to?(method_name)
152
- self.class.send(:define_method, method_name) do |*method_args, &method_block|
153
- @data.send(method_name, *method_args, &method_block)
154
- end
155
- send(method_name, *args, &block)
156
- else
157
- super
158
- end
258
+ return super unless @data.respond_to?(method_name)
259
+ @data.send(method_name, *args, &block)
159
260
  end
160
261
 
161
-
262
+ # Checks if the DataFrame responds to a method.
263
+ #
264
+ # @param method_name [Symbol] Method name to check
265
+ # @param include_private [Boolean] Include private methods
266
+ # @return [Boolean] true if method is available
162
267
  def respond_to_missing?(method_name, include_private = false)
163
268
  @data.respond_to?(method_name) || super
164
269
  end
@@ -183,45 +288,74 @@ class SQA::DataFrame
183
288
  new(df, mapping: mapping, transformers: transformers)
184
289
  end
185
290
 
291
+ # Creates a DataFrame from an array of hashes.
292
+ #
293
+ # @param aofh [Array<Hash>] Array of hash records
294
+ # @param mapping [Hash] Column name mappings to apply
295
+ # @param transformers [Hash] Column transformers to apply
296
+ # @return [SQA::DataFrame] New DataFrame instance
297
+ #
298
+ # @example
299
+ # data = [{ "date" => "2024-01-01", "price" => 100.0 }]
300
+ # df = SQA::DataFrame.from_aofh(data)
301
+ #
186
302
  def from_aofh(aofh, mapping: {}, transformers: {})
303
+ return new({}, mapping: mapping, transformers: transformers) if aofh.empty?
304
+
305
+ # Sanitize keys to strings and convert to hash of arrays (Polars-compatible format)
187
306
  aoh_sanitized = aofh.map { |entry| entry.transform_keys(&:to_s) }
188
307
  columns = aoh_sanitized.first.keys
189
- data = aoh_sanitized.map(&:values)
190
- df = Polars::DataFrame.new(
191
- data,
192
- columns: columns
193
- )
194
- new(df)
195
- end
196
308
 
197
-
198
- def from_csv_file(source, mapping: {}, transformers: {})
199
- debug_me do
200
- %i[
201
- source
202
- mapping
203
- transformers
204
- ]
309
+ # Convert array-of-hashes to hash-of-arrays for Polars
310
+ hofa = columns.each_with_object({}) do |col, hash|
311
+ hash[col] = aoh_sanitized.map { |row| row[col] }
205
312
  end
206
313
 
207
- df = Polars.read_csv(source)
314
+ df = Polars::DataFrame.new(hofa)
208
315
  new(df, mapping: mapping, transformers: transformers)
209
316
  end
210
317
 
318
+ # Creates a DataFrame from a CSV file.
319
+ #
320
+ # @param source [String, Pathname] Path to CSV file
321
+ # @param mapping [Hash] Column name mappings to apply
322
+ # @param transformers [Hash] Column transformers to apply
323
+ # @return [SQA::DataFrame] New DataFrame instance
324
+ def from_csv_file(source, mapping: {}, transformers: {})
325
+ df = Polars.read_csv(source)
326
+ new(df, mapping: mapping, transformers: transformers)
327
+ end
211
328
 
329
+ # Creates a DataFrame from a JSON file.
330
+ #
331
+ # @param source [String, Pathname] Path to JSON file containing array of objects
332
+ # @param mapping [Hash] Column name mappings to apply
333
+ # @param transformers [Hash] Column transformers to apply
334
+ # @return [SQA::DataFrame] New DataFrame instance
212
335
  def from_json_file(source, mapping: {}, transformers: {})
213
336
  aofh = JSON.parse(File.read(source)).map { |entry| entry.transform_keys(&:to_s) }
214
337
  from_aofh(aofh, mapping: mapping, transformers: transformers)
215
338
  end
216
339
 
217
-
340
+ # Generates a mapping of original keys to underscored keys.
341
+ #
342
+ # @param keys [Array<String>] Original key names
343
+ # @return [Hash{String => Symbol}] Mapping from original to underscored keys
218
344
  def generate_mapping(keys)
219
345
  keys.each_with_object({}) do |key, hash|
220
346
  hash[key.to_s] = underscore_key(key.to_s)
221
347
  end
222
348
  end
223
349
 
224
-
350
+ # Converts a key string to underscored snake_case format.
351
+ #
352
+ # @param key [String] Key to convert
353
+ # @return [Symbol] Underscored key as symbol
354
+ #
355
+ # @example
356
+ # underscore_key("closePrice") # => :close_price
357
+ # underscore_key("Close Price") # => :close_price
358
+ #
225
359
  def underscore_key(key)
226
360
  key.to_s
227
361
  .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
@@ -236,19 +370,33 @@ class SQA::DataFrame
236
370
 
237
371
  alias sanitize_key underscore_key
238
372
 
373
+ # Normalizes all keys in a hash to snake_case format.
374
+ #
375
+ # @param hash [Hash] Hash with keys to normalize
376
+ # @param adapter_mapping [Hash] Optional pre-mapping to apply first
377
+ # @return [Hash] Hash with normalized keys
239
378
  def normalize_keys(hash, adapter_mapping: {})
240
379
  hash = rename(hash, adapter_mapping) unless adapter_mapping.empty?
241
380
  mapping = generate_mapping(hash.keys)
242
381
  rename(hash, mapping)
243
382
  end
244
383
 
245
-
384
+ # Renames keys in a hash according to a mapping.
385
+ #
386
+ # @param hash [Hash] Hash to modify
387
+ # @param mapping [Hash] Old key to new key mapping
388
+ # @return [Hash] Modified hash
246
389
  def rename(hash, mapping)
247
390
  mapping.each { |old_key, new_key| hash[new_key] = hash.delete(old_key) if hash.key?(old_key) }
248
391
  hash
249
392
  end
250
393
 
251
-
394
+ # Converts array of hashes to hash of arrays format.
395
+ #
396
+ # @param aofh [Array<Hash>] Array of hash records
397
+ # @param mapping [Hash] Column name mappings (unused, for API compatibility)
398
+ # @param transformers [Hash] Column transformers (unused, for API compatibility)
399
+ # @return [Hash{String => Array}] Hash with column names as keys and arrays as values
252
400
  def aofh_to_hofa(aofh, mapping: {}, transformers: {})
253
401
  hofa = Hash.new { |h, k| h[k.downcase] = [] }
254
402
  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/indicator.rb CHANGED
@@ -1,8 +1,21 @@
1
1
  # lib/sqa/indicator.rb
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'sqa/tai'
4
+ # Try to load TA-Lib indicators, but make them optional
5
+ # This allows the library to work without TA-Lib installed for basic data loading
6
+ begin
7
+ require 'sqa/tai'
5
8
 
6
- # Use SQA::TAI directly for all technical analysis indicators
7
- # SQAI is a shortcut alias for SQA::TAI
8
- SQAI = SQA::TAI
9
+ # Use SQA::TAI directly for all technical analysis indicators
10
+ # SQAI is a shortcut alias for SQA::TAI
11
+ SQAI = SQA::TAI
12
+ rescue LoadError, Fiddle::DLError => e
13
+ # TA-Lib not available - define a stub that gives helpful errors
14
+ warn "Warning: TA-Lib not available (#{e.class}: #{e.message}). Technical indicators will not work." if $VERBOSE
15
+
16
+ module SQAI
17
+ def self.method_missing(method, *args, &block)
18
+ raise "Technical indicators require TA-Lib to be installed. Please install libta-lib system library. Visit: http://ta-lib.org/hdr_dw.html"
19
+ end
20
+ end
21
+ 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?)
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