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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/CLAUDE.md +21 -0
- data/README.md +60 -32
- data/Rakefile +52 -10
- data/docs/IMPROVEMENT_PLAN.md +531 -0
- data/docs/advanced/index.md +1 -13
- data/docs/api/dataframe.md +0 -1
- 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 +137 -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 +752 -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 +455 -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 +649 -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/sqa.jpg +0 -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 -27
- data/docs/data_frame.md +0 -1
- data/docs/getting-started/index.md +1 -30
- data/docs/getting-started/installation.md +2 -2
- data/docs/getting-started/quick-start.md +4 -4
- data/docs/index.md +26 -25
- data/docs/llms.txt +109 -0
- data/docs/strategies/bollinger-bands.md +1 -1
- data/docs/strategies/kbs.md +15 -14
- data/docs/strategies/rsi.md +1 -1
- 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/config.rb +109 -28
- data/lib/sqa/data_frame/alpha_vantage.rb +13 -3
- data/lib/sqa/data_frame/data.rb +13 -1
- data/lib/sqa/data_frame.rb +189 -41
- data/lib/sqa/errors.rb +79 -17
- data/lib/sqa/indicator.rb +17 -4
- data/lib/sqa/init.rb +70 -15
- data/lib/sqa/pattern_matcher.rb +4 -4
- data/lib/sqa/portfolio.rb +1 -1
- data/lib/sqa/sector_analyzer.rb +3 -11
- data/lib/sqa/stock.rb +236 -20
- data/lib/sqa/strategy.rb +62 -4
- data/lib/sqa/ticker.rb +107 -42
- data/lib/sqa/version.rb +1 -1
- data/lib/sqa.rb +16 -8
- data/mkdocs.yml +68 -117
- metadata +90 -36
- data/docs/README.md +0 -43
- data/docs/alpha_vantage_technical_indicators.md +0 -62
- data/docs/average_true_range.md +0 -9
- data/docs/bollinger_bands.md +0 -15
- data/docs/candlestick_pattern_recognizer.md +0 -4
- data/docs/donchian_channel.md +0 -5
- data/docs/double_top_bottom_pattern.md +0 -3
- data/docs/exponential_moving_average.md +0 -19
- data/docs/fibonacci_retracement.md +0 -30
- data/docs/head_and_shoulders_pattern.md +0 -3
- data/docs/market_profile.md +0 -4
- data/docs/momentum.md +0 -19
- data/docs/moving_average_convergence_divergence.md +0 -23
- data/docs/peaks_and_valleys.md +0 -11
- data/docs/relative_strength_index.md +0 -6
- data/docs/simple_moving_average.md +0 -8
- data/docs/stochastic_oscillator.md +0 -4
- data/docs/ta_lib.md +0 -160
- data/docs/true_range.md +0 -12
- data/docs/true_strength_index.md +0 -46
- data/docs/weighted_moving_average.md +0 -48
- data/examples/sinatra_app/Gemfile +0 -22
- data/examples/sinatra_app/QUICKSTART.md +0 -159
- data/examples/sinatra_app/README.md +0 -461
- data/examples/sinatra_app/app.rb +0 -344
- data/examples/sinatra_app/config.ru +0 -5
- data/examples/sinatra_app/public/css/style.css +0 -659
- data/examples/sinatra_app/public/js/app.js +0 -107
- 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 -419
- 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,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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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.
|
|
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
|
-
|
|
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/indicator.rb
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# lib/sqa/indicator.rb
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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?)
|
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
|