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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +154 -1
- data/README.md +4 -0
- data/Rakefile +52 -10
- data/docs/advanced/index.md +1 -13
- data/docs/api/index.md +547 -61
- data/docs/api-reference/alphavantageapi.md +1057 -0
- data/docs/api-reference/apierror.md +31 -0
- data/docs/api-reference/index.md +221 -0
- data/docs/api-reference/notimplemented.md +27 -0
- data/docs/api-reference/sqa.md +267 -0
- data/docs/api-reference/sqa_backtest.md +171 -0
- data/docs/api-reference/sqa_backtest_results.md +530 -0
- data/docs/api-reference/sqa_badparametererror.md +13 -0
- data/docs/api-reference/sqa_config.md +538 -0
- data/docs/api-reference/sqa_configurationerror.md +13 -0
- data/docs/api-reference/sqa_datafetcherror.md +56 -0
- data/docs/api-reference/sqa_dataframe.md +779 -0
- data/docs/api-reference/sqa_dataframe_alphavantage.md +30 -0
- data/docs/api-reference/sqa_dataframe_data.md +325 -0
- data/docs/api-reference/sqa_dataframe_yahoofinance.md +25 -0
- data/docs/api-reference/sqa_ensemble.md +413 -0
- data/docs/api-reference/sqa_fpop.md +211 -0
- data/docs/api-reference/sqa_geneticprogram.md +325 -0
- data/docs/api-reference/sqa_geneticprogram_individual.md +114 -0
- data/docs/api-reference/sqa_marketregime.md +212 -0
- data/docs/api-reference/sqa_multitimeframe.md +227 -0
- data/docs/api-reference/sqa_patternmatcher.md +195 -0
- data/docs/api-reference/sqa_pluginmanager.md +55 -0
- data/docs/api-reference/sqa_portfolio.md +512 -0
- data/docs/api-reference/sqa_portfolio_position.md +220 -0
- data/docs/api-reference/sqa_portfolio_trade.md +332 -0
- data/docs/api-reference/sqa_portfoliooptimizer.md +248 -0
- data/docs/api-reference/sqa_riskmanager.md +388 -0
- data/docs/api-reference/sqa_seasonalanalyzer.md +121 -0
- data/docs/api-reference/sqa_sectoranalyzer.md +163 -0
- data/docs/api-reference/sqa_stock.md +661 -0
- data/docs/api-reference/sqa_strategy.md +178 -0
- data/docs/api-reference/sqa_strategy_bollingerbands.md +26 -0
- data/docs/api-reference/sqa_strategy_common.md +29 -0
- data/docs/api-reference/sqa_strategy_consensus.md +129 -0
- data/docs/api-reference/sqa_strategy_ema.md +41 -0
- data/docs/api-reference/sqa_strategy_kbs.md +154 -0
- data/docs/api-reference/sqa_strategy_macd.md +26 -0
- data/docs/api-reference/sqa_strategy_mp.md +41 -0
- data/docs/api-reference/sqa_strategy_mr.md +41 -0
- data/docs/api-reference/sqa_strategy_random.md +41 -0
- data/docs/api-reference/sqa_strategy_rsi.md +41 -0
- data/docs/api-reference/sqa_strategy_sma.md +41 -0
- data/docs/api-reference/sqa_strategy_stochastic.md +26 -0
- data/docs/api-reference/sqa_strategy_volumebreakout.md +26 -0
- data/docs/api-reference/sqa_strategygenerator.md +298 -0
- data/docs/api-reference/sqa_strategygenerator_pattern.md +264 -0
- data/docs/api-reference/sqa_strategygenerator_patterncontext.md +326 -0
- data/docs/api-reference/sqa_strategygenerator_profitablepoint.md +424 -0
- data/docs/api-reference/sqa_stream.md +256 -0
- data/docs/api-reference/sqa_ticker.md +175 -0
- data/docs/api-reference/string.md +135 -0
- data/docs/assets/images/advanced-workflow.svg +89 -0
- data/docs/assets/images/architecture.svg +107 -0
- data/docs/assets/images/data-flow.svg +138 -0
- data/docs/assets/images/getting-started-workflow.svg +88 -0
- data/docs/assets/images/strategy-flow.svg +78 -0
- data/docs/assets/images/system-architecture.svg +150 -0
- data/docs/concepts/index.md +292 -19
- data/docs/file_formats.md +250 -0
- data/docs/getting-started/index.md +1 -14
- data/docs/index.md +26 -23
- data/docs/llms.txt +109 -0
- data/docs/strategies/kbs.md +15 -14
- data/docs/strategy.md +381 -3
- data/docs/terms_of_use.md +1 -1
- data/examples/README.md +10 -0
- data/lib/api/alpha_vantage_api.rb +3 -7
- data/lib/sqa/backtest.rb +32 -0
- data/lib/sqa/config.rb +109 -28
- data/lib/sqa/data_frame/data.rb +13 -1
- data/lib/sqa/data_frame.rb +193 -26
- data/lib/sqa/errors.rb +79 -17
- data/lib/sqa/init.rb +70 -15
- data/lib/sqa/pattern_matcher.rb +4 -4
- data/lib/sqa/portfolio.rb +55 -1
- data/lib/sqa/sector_analyzer.rb +3 -11
- data/lib/sqa/stock.rb +180 -15
- data/lib/sqa/strategy.rb +62 -4
- data/lib/sqa/ticker.rb +106 -48
- data/lib/sqa/version.rb +1 -1
- data/lib/sqa.rb +4 -4
- data/mkdocs.yml +69 -81
- metadata +89 -21
- data/docs/README.md +0 -43
- data/examples/sinatra_app/Gemfile +0 -42
- data/examples/sinatra_app/Gemfile.lock +0 -268
- data/examples/sinatra_app/QUICKSTART.md +0 -169
- data/examples/sinatra_app/README.md +0 -471
- data/examples/sinatra_app/RUNNING_WITHOUT_TALIB.md +0 -90
- data/examples/sinatra_app/TROUBLESHOOTING.md +0 -95
- data/examples/sinatra_app/app.rb +0 -404
- data/examples/sinatra_app/config.ru +0 -5
- data/examples/sinatra_app/public/css/style.css +0 -723
- data/examples/sinatra_app/public/debug_macd.html +0 -82
- data/examples/sinatra_app/public/js/app.js +0 -107
- data/examples/sinatra_app/start.sh +0 -53
- data/examples/sinatra_app/views/analyze.erb +0 -306
- data/examples/sinatra_app/views/backtest.erb +0 -325
- data/examples/sinatra_app/views/dashboard.erb +0 -831
- data/examples/sinatra_app/views/error.erb +0 -58
- data/examples/sinatra_app/views/index.erb +0 -118
- data/examples/sinatra_app/views/layout.erb +0 -61
- data/examples/sinatra_app/views/portfolio.erb +0 -43
data/lib/sqa/data_frame/data.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
data/lib/sqa/data_frame.rb
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
20
|
+
# @!attribute [w] config
|
|
21
|
+
# @return [SQA::Config] Configuration instance
|
|
22
|
+
attr_writer :config
|
|
7
23
|
|
|
8
|
-
# Initializes the SQA
|
|
9
|
-
#
|
|
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
|
-
#
|
|
41
|
+
# @config = Config.new
|
|
19
42
|
|
|
20
43
|
if defined? CLI
|
|
21
|
-
CLI.run! #
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
108
|
+
# Returns the current configuration.
|
|
109
|
+
#
|
|
110
|
+
# @return [SQA::Config] Configuration instance
|
|
111
|
+
def config() = @config
|
|
57
112
|
end
|
|
58
113
|
end
|
data/lib/sqa/pattern_matcher.rb
CHANGED
|
@@ -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?)
|