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
@@ -3,13 +3,9 @@
3
3
  require 'faraday'
4
4
  require 'json'
5
5
 
6
- # TODO: Reorganize the methods by category
7
- # Market Data
8
- # Technical Indicators
9
- # Trading
10
- # Economic Indicators
11
- # Digital and Forex
12
- #
6
+ # Alpha Vantage API wrapper
7
+ # Categories: Market Data, Technical Indicators, Trading, Economic Indicators, Digital/Forex
8
+ # See: https://www.alphavantage.co/documentation/
13
9
 
14
10
 
15
11
  class AlphaVantageAPI
data/lib/sqa/config.rb CHANGED
@@ -1,30 +1,63 @@
1
1
  # lib/sqa/config.rb
2
2
 
3
- # The hierarchies of values should be:
4
- # default
5
- # envar ..... overrides default
6
- # config file ..... overrides envar
7
- # command line parameters ...... overrides config file
8
-
3
+ # Configuration management for SQA with hierarchical value resolution.
4
+ # Values are resolved in this order (later overrides earlier):
5
+ # 1. default values
6
+ # 2. environment variables (SQA_ prefix)
7
+ # 3. config file (YAML, TOML, or JSON)
8
+ # 4. command line parameters
9
+ #
10
+ # @example Basic configuration
11
+ # SQA.init
12
+ # SQA.config.data_dir = "~/my_data"
13
+ # SQA.config.debug = true
14
+ #
15
+ # @example Using config file
16
+ # SQA.config.config_file = "~/.sqa.yml"
17
+ # SQA.config.from_file
18
+ #
19
+ # @example Environment variables
20
+ # # Set SQA_DATA_DIR, SQA_DEBUG, etc. before requiring sqa
21
+ #
22
+
23
+ require 'fileutils'
9
24
  require 'yaml'
10
25
  require 'toml-rb'
11
26
 
12
27
  module SQA
13
- # class Config < Hashie::Trash
14
- # include Hashie::Extensions::IgnoreUndeclared
15
- # include Hashie::Extensions::Coercion
16
-
17
-
28
+ # Configuration class for SQA settings.
29
+ # Extends Hashie::Dash for property-based configuration with coercion.
30
+ #
31
+ # @!attribute [rw] command
32
+ # @return [String, nil] Current command (nil, 'analysis', or 'web')
33
+ # @!attribute [rw] config_file
34
+ # @return [String, nil] Path to configuration file
35
+ # @!attribute [rw] dump_config
36
+ # @return [String, nil] Path to dump current configuration
37
+ # @!attribute [rw] data_dir
38
+ # @return [String] Directory for data storage (default: ~/sqa_data)
39
+ # @!attribute [rw] portfolio_filename
40
+ # @return [String] Portfolio CSV filename (default: portfolio.csv)
41
+ # @!attribute [rw] trades_filename
42
+ # @return [String] Trades CSV filename (default: trades.csv)
43
+ # @!attribute [rw] log_level
44
+ # @return [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
45
+ # @!attribute [rw] debug
46
+ # @return [Boolean] Enable debug mode
47
+ # @!attribute [rw] verbose
48
+ # @return [Boolean] Enable verbose output
49
+ # @!attribute [rw] plotting_library
50
+ # @return [Symbol] Plotting library to use (:gruff)
51
+ # @!attribute [rw] lazy_update
52
+ # @return [Boolean] Skip API updates if cached data exists
53
+ #
18
54
  class Config < Hashie::Dash
19
55
  include Hashie::Extensions::Dash::PropertyTranslation
20
56
  include Hashie::Extensions::MethodAccess
21
57
  include Hashie::Extensions::Coercion
22
58
 
23
- # FIXME: Getting undefined error PredefinedValues
24
- # I'm thinking that Ruby is dropping it from the ObjectSpace
25
- # Looks like it is only used for the log level. Should
26
- # able to work around that.
27
- #
59
+ # NOTE: PredefinedValues extension disabled due to compatibility issues.
60
+ # Log level validation is handled via the `values:` option on the property instead.
28
61
  # include Hashie::Extensions::Dash::PredefinedValues
29
62
 
30
63
  property :command # a String currently, nil, analysis or web
@@ -33,18 +66,17 @@ module SQA
33
66
 
34
67
  property :data_dir, default: Nenv.home + "/sqa_data"
35
68
 
36
- # TODO: If no path is given, these files will be in
37
- # data directory, otherwise, use the given path
69
+ # Relative filenames are resolved against data_dir; absolute paths used as-is
38
70
  property :portfolio_filename, from: :portfolio, default: "portfolio.csv"
39
71
  property :trades_filename, from: :trades, default: "trades.csv"
40
72
 
41
73
  property :log_level, default: :info, coerce: Symbol, values: %i[debug info warn error fatal]
42
74
 
43
- # TODO: need a custom proc since there is no Boolean class in Ruby
44
- property :debug, default: false #, coerce: Boolean
45
- property :verbose, default: false #, coerce: Boolean
75
+ # Boolean coercion handled via coerce_key blocks below (no Boolean class in Ruby)
76
+ property :debug, default: false
77
+ property :verbose, default: false
46
78
 
47
- # TODO: use svggraph
79
+ # Plotting library - gruff is default; svggraph support could be added in future
48
80
  property :plotting_library, from: :plot_lib, default: :gruff, coerce: Symbol
49
81
  property :lazy_update, from: :lazy, default: false
50
82
 
@@ -71,17 +103,41 @@ module SQA
71
103
  end
72
104
  end
73
105
 
106
+ coerce_key :log_level, ->(v) do
107
+ v.is_a?(String) ? v.to_sym : v
108
+ end
109
+
110
+ coerce_key :plotting_library, ->(v) do
111
+ v.is_a?(String) ? v.to_sym : v
112
+ end
113
+
74
114
  ########################################################
115
+
116
+ # Creates a new Config instance with optional initial values.
117
+ # Automatically applies environment variable overrides.
118
+ #
119
+ # @param a_hash [Hash] Initial configuration values
75
120
  def initialize(a_hash={})
76
121
  super(a_hash)
77
122
  override_with_envars
78
123
  end
79
124
 
125
+ # Returns whether debug mode is enabled.
126
+ # @return [Boolean] true if debug mode is on
80
127
  def debug? = debug
128
+
129
+ # Returns whether verbose mode is enabled.
130
+ # @return [Boolean] true if verbose mode is on
81
131
  def verbose? = verbose
82
132
 
83
133
 
84
134
  ########################################################
135
+
136
+ # Loads configuration from a file.
137
+ # Supports YAML (.yml, .yaml), TOML (.toml), and JSON (.json) formats.
138
+ #
139
+ # @return [void]
140
+ # @raise [BadParameterError] If config file is invalid or unsupported format
85
141
  def from_file
86
142
  return if config_file.nil?
87
143
 
@@ -93,8 +149,7 @@ module SQA
93
149
  type = "invalid"
94
150
  end
95
151
 
96
- # TODO: arrange order in mostly often used
97
-
152
+ # Config file format detection (YAML is most common)
98
153
  if ".json" == type
99
154
  incoming = form_json
100
155
 
@@ -108,19 +163,24 @@ module SQA
108
163
  raise BadParameterError, "Invalid Config File: #{config_file}"
109
164
  end
110
165
 
111
- if incoming.has_key? :data_dir
166
+ if incoming.key?(:data_dir)
112
167
  incoming[:data_dir] = incoming[:data_dir].gsub(/^~/, Nenv.home)
113
168
  end
114
169
 
115
170
  merge! incoming
116
171
  end
117
172
 
173
+ # Writes current configuration to a file.
174
+ # Format is determined by file extension.
175
+ #
176
+ # @return [void]
177
+ # @raise [BadParameterError] If config file is not set or unsupported format
118
178
  def dump_file
119
179
  if config_file.nil?
120
180
  raise BadParameterError, "No config file given"
121
181
  end
122
182
 
123
- `touch #{config_file}`
183
+ FileUtils.touch(config_file)
124
184
  # unless File.exist?(config_file)
125
185
 
126
186
  type = File.extname(config_file).downcase
@@ -139,7 +199,10 @@ module SQA
139
199
  end
140
200
  end
141
201
 
142
- # Method to dynamically extend properties from external sources (e.g., plugins)
202
+ # Injects additional properties from plugins.
203
+ # Allows external code to register new configuration options.
204
+ #
205
+ # @return [void]
143
206
  def inject_additional_properties
144
207
  SQA::PluginManager.registered_properties.each do |prop, options|
145
208
  self.class.property(prop, options)
@@ -185,11 +248,29 @@ module SQA
185
248
 
186
249
  #####################################
187
250
  class << self
251
+ # Resets the configuration to default values.
252
+ # Creates a new Config instance and assigns it to SQA.config.
253
+ #
254
+ # @return [SQA::Config] The new config instance
188
255
  def reset
256
+ @initialized = true
189
257
  SQA.config = new
190
258
  end
259
+
260
+ # Returns whether the configuration has been initialized.
261
+ #
262
+ # @return [Boolean] true if reset has been called
263
+ def initialized?
264
+ @initialized ||= false
265
+ end
191
266
  end
192
267
  end
193
268
  end
194
269
 
195
- SQA::Config.reset
270
+ # Auto-initialization with deprecation warning
271
+ # This will be removed in v1.0.0 - applications should call SQA.init explicitly
272
+ unless SQA::Config.initialized?
273
+ warn "[SQA DEPRECATION] Auto-initialization at require time will be removed in v1.0. " \
274
+ "Please call SQA.init explicitly in your application startup." if $VERBOSE
275
+ SQA::Config.reset
276
+ end
@@ -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,16 @@ 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.
79
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
+
80
142
  # Concatenate the dataframes
81
143
  @data = if @data.shape[0] == 0
82
144
  other_df.data
@@ -91,32 +153,53 @@ class SQA::DataFrame
91
153
  @data = @data.sort(sort_column, reverse: descending)
92
154
  end
93
155
 
156
+ # Returns the column names of the DataFrame.
157
+ #
158
+ # @return [Array<String>] List of column names
94
159
  def columns
95
160
  @data.columns
96
161
  end
97
162
 
98
-
163
+ # Returns the column names of the DataFrame.
164
+ # Alias for {#columns}.
165
+ #
166
+ # @return [Array<String>] List of column names
99
167
  def keys
100
168
  @data.columns
101
169
  end
102
170
  alias vectors keys
103
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
+ #
104
179
  def to_h
105
180
  @data.columns.map { |col| [col.to_sym, @data[col].to_a] }.to_h
106
181
  end
107
182
 
108
-
183
+ # Writes the DataFrame to a CSV file.
184
+ #
185
+ # @param path_to_file [String, Pathname] Path to output CSV file
186
+ # @return [void]
109
187
  def to_csv(path_to_file)
110
188
  @data.write_csv(path_to_file)
111
189
  end
112
190
 
113
-
191
+ # Returns the number of rows in the DataFrame.
192
+ #
193
+ # @return [Integer] Row count
114
194
  def size
115
195
  @data.height
116
196
  end
117
197
  alias nrows size
118
198
  alias length size
119
199
 
200
+ # Returns the number of columns in the DataFrame.
201
+ #
202
+ # @return [Integer] Column count
120
203
  def ncols
121
204
  @data.width
122
205
  end
@@ -156,23 +239,31 @@ class SQA::DataFrame
156
239
  end
157
240
 
158
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
159
246
  def self.is_date?(value)
160
247
  value.is_a?(String) && !/\d{4}-\d{2}-\d{2}/.match(value).nil?
161
248
  end
162
249
 
163
-
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
164
257
  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
258
+ return super unless @data.respond_to?(method_name)
259
+ @data.send(method_name, *args, &block)
173
260
  end
174
261
 
175
-
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
176
267
  def respond_to_missing?(method_name, include_private = false)
177
268
  @data.respond_to?(method_name) || super
178
269
  end
@@ -197,37 +288,74 @@ class SQA::DataFrame
197
288
  new(df, mapping: mapping, transformers: transformers)
198
289
  end
199
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
+ #
200
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)
201
306
  aoh_sanitized = aofh.map { |entry| entry.transform_keys(&:to_s) }
202
307
  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
308
 
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] }
312
+ end
313
+
314
+ df = Polars::DataFrame.new(hofa)
315
+ new(df, mapping: mapping, transformers: transformers)
316
+ end
211
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
212
324
  def from_csv_file(source, mapping: {}, transformers: {})
213
325
  df = Polars.read_csv(source)
214
326
  new(df, mapping: mapping, transformers: transformers)
215
327
  end
216
328
 
217
-
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
218
335
  def from_json_file(source, mapping: {}, transformers: {})
219
336
  aofh = JSON.parse(File.read(source)).map { |entry| entry.transform_keys(&:to_s) }
220
337
  from_aofh(aofh, mapping: mapping, transformers: transformers)
221
338
  end
222
339
 
223
-
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
224
344
  def generate_mapping(keys)
225
345
  keys.each_with_object({}) do |key, hash|
226
346
  hash[key.to_s] = underscore_key(key.to_s)
227
347
  end
228
348
  end
229
349
 
230
-
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
+ #
231
359
  def underscore_key(key)
232
360
  key.to_s
233
361
  .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
@@ -242,19 +370,33 @@ class SQA::DataFrame
242
370
 
243
371
  alias sanitize_key underscore_key
244
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
245
378
  def normalize_keys(hash, adapter_mapping: {})
246
379
  hash = rename(hash, adapter_mapping) unless adapter_mapping.empty?
247
380
  mapping = generate_mapping(hash.keys)
248
381
  rename(hash, mapping)
249
382
  end
250
383
 
251
-
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
252
389
  def rename(hash, mapping)
253
390
  mapping.each { |old_key, new_key| hash[new_key] = hash.delete(old_key) if hash.key?(old_key) }
254
391
  hash
255
392
  end
256
393
 
257
-
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
258
400
  def aofh_to_hofa(aofh, mapping: {}, transformers: {})
259
401
  hofa = Hash.new { |h, k| h[k.downcase] = [] }
260
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