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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -1
- data/README.md +4 -0
- data/Rakefile +52 -10
- data/docs/IMPROVEMENT_PLAN.md +531 -0
- 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 +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/strategy-flow.svg +78 -0
- data/docs/assets/images/system-architecture.svg +150 -0
- data/docs/concepts/index.md +292 -19
- 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/config.rb +109 -28
- data/lib/sqa/data_frame/data.rb +13 -1
- data/lib/sqa/data_frame.rb +168 -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 +1 -1
- data/lib/sqa/sector_analyzer.rb +3 -11
- data/lib/sqa/stock.rb +169 -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 +68 -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
|
@@ -3,13 +3,9 @@
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
24
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
44
|
-
property :debug, default: false
|
|
45
|
-
property :verbose, default: false
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
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,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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|