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/sector_analyzer.rb
CHANGED
|
@@ -89,15 +89,11 @@ module SQA
|
|
|
89
89
|
kb = @blackboards[sector]
|
|
90
90
|
all_patterns = []
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
puts "Discovering patterns for #{sector.to_s.upcase} sector"
|
|
94
|
-
puts "Analyzing #{stocks.size} stocks: #{stocks.map(&:ticker).join(', ')}"
|
|
95
|
-
puts "=" * 70
|
|
96
|
-
puts
|
|
92
|
+
debug_me {"Discovering patterns for #{sector.to_s.upcase} sector - #{stocks.size} stocks"}
|
|
97
93
|
|
|
98
94
|
# Discover patterns for each stock
|
|
99
95
|
stocks.each do |stock|
|
|
100
|
-
|
|
96
|
+
debug_me {"Analyzing #{stock.ticker}..."}
|
|
101
97
|
|
|
102
98
|
generator = SQA::StrategyGenerator.new(stock: stock, **options)
|
|
103
99
|
patterns = generator.discover_patterns
|
|
@@ -136,11 +132,7 @@ module SQA
|
|
|
136
132
|
})
|
|
137
133
|
end
|
|
138
134
|
|
|
139
|
-
|
|
140
|
-
puts "Sector Analysis Complete"
|
|
141
|
-
puts " Individual patterns found: #{all_patterns.size}"
|
|
142
|
-
puts " Sector-wide patterns: #{sector_patterns.size}"
|
|
143
|
-
puts "=" * 70
|
|
135
|
+
debug_me {"Sector Analysis Complete - #{all_patterns.size} individual, #{sector_patterns.size} sector-wide patterns"}
|
|
144
136
|
|
|
145
137
|
sector_patterns
|
|
146
138
|
end
|
data/lib/sqa/stock.rb
CHANGED
|
@@ -1,53 +1,176 @@
|
|
|
1
1
|
# lib/sqa/stock.rb
|
|
2
2
|
|
|
3
|
+
# Represents a stock with price history, metadata, and technical analysis capabilities.
|
|
4
|
+
# This is the primary domain object for interacting with stock data.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# stock = SQA::Stock.new(ticker: 'AAPL')
|
|
8
|
+
# prices = stock.df["adj_close_price"].to_a
|
|
9
|
+
# puts stock.to_s
|
|
10
|
+
#
|
|
11
|
+
# @example With different data source
|
|
12
|
+
# stock = SQA::Stock.new(ticker: 'MSFT', source: :yahoo_finance)
|
|
13
|
+
#
|
|
3
14
|
class SQA::Stock
|
|
4
15
|
extend Forwardable
|
|
5
16
|
|
|
6
|
-
|
|
17
|
+
# Default Alpha Vantage API URL
|
|
18
|
+
# @return [String] The base URL for Alpha Vantage API
|
|
19
|
+
ALPHA_VANTAGE_URL = "https://www.alphavantage.co".freeze
|
|
7
20
|
|
|
21
|
+
# @deprecated Use {.connection} method instead. Will be removed in v1.0.0
|
|
22
|
+
# @return [Faraday::Connection] Legacy constant for backward compatibility
|
|
23
|
+
CONNECTION = Faraday.new(url: ALPHA_VANTAGE_URL)
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Returns the current Faraday connection for API requests.
|
|
27
|
+
# Allows injection of custom connections for testing or different configurations.
|
|
28
|
+
#
|
|
29
|
+
# @return [Faraday::Connection] The current connection instance
|
|
30
|
+
def connection
|
|
31
|
+
@connection ||= default_connection
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Sets a custom Faraday connection.
|
|
35
|
+
# Useful for testing with mocks/stubs or configuring different API endpoints.
|
|
36
|
+
#
|
|
37
|
+
# @param conn [Faraday::Connection] Custom Faraday connection to use
|
|
38
|
+
# @return [Faraday::Connection] The connection that was set
|
|
39
|
+
def connection=(conn)
|
|
40
|
+
@connection = conn
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Creates the default Faraday connection to Alpha Vantage.
|
|
44
|
+
#
|
|
45
|
+
# @return [Faraday::Connection] A new connection to Alpha Vantage API
|
|
46
|
+
def default_connection
|
|
47
|
+
Faraday.new(url: ALPHA_VANTAGE_URL)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resets the connection to default.
|
|
51
|
+
# Useful for testing cleanup to ensure fresh state between tests.
|
|
52
|
+
#
|
|
53
|
+
# @return [nil]
|
|
54
|
+
def reset_connection!
|
|
55
|
+
@connection = nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @!attribute [rw] data
|
|
60
|
+
# @return [SQA::DataFrame::Data] Stock metadata (ticker, name, exchange, etc.)
|
|
61
|
+
# @!attribute [rw] df
|
|
62
|
+
# @return [SQA::DataFrame] Price and volume data as a DataFrame
|
|
63
|
+
# @!attribute [rw] klass
|
|
64
|
+
# @return [Class] The data source class (e.g., SQA::DataFrame::AlphaVantage)
|
|
65
|
+
# @!attribute [rw] transformers
|
|
66
|
+
# @return [Hash] Column transformers for data normalization
|
|
67
|
+
# @!attribute [rw] strategy
|
|
68
|
+
# @return [SQA::Strategy, nil] Optional trading strategy attached to this stock
|
|
8
69
|
attr_accessor :data, :df, :klass, :transformers, :strategy
|
|
9
70
|
|
|
71
|
+
# Creates a new Stock instance and loads or fetches its data.
|
|
72
|
+
#
|
|
73
|
+
# @param ticker [String] The stock ticker symbol (e.g., 'AAPL', 'MSFT')
|
|
74
|
+
# @param source [Symbol] The data source to use (:alpha_vantage or :yahoo_finance)
|
|
75
|
+
# @raise [SQA::DataFetchError] If data cannot be fetched and no cached data exists
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# stock = SQA::Stock.new(ticker: 'AAPL')
|
|
79
|
+
# stock = SQA::Stock.new(ticker: 'GOOG', source: :yahoo_finance)
|
|
80
|
+
#
|
|
10
81
|
def initialize(ticker:, source: :alpha_vantage)
|
|
11
82
|
@ticker = ticker.downcase
|
|
12
83
|
@source = source
|
|
13
84
|
|
|
14
|
-
raise "Invalid Ticker #{ticker}" unless SQA::Ticker.valid?(ticker)
|
|
15
|
-
|
|
16
85
|
@data_path = SQA.data_dir + "#{@ticker}.json"
|
|
17
86
|
@df_path = SQA.data_dir + "#{@ticker}.csv"
|
|
18
87
|
|
|
88
|
+
# Validate ticker if validation data is available and cached data doesn't exist
|
|
89
|
+
unless @data_path.exist? && @df_path.exist?
|
|
90
|
+
unless SQA::Ticker.valid?(ticker)
|
|
91
|
+
warn "Warning: Ticker #{ticker} could not be validated. Proceeding anyway." if $VERBOSE
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
19
95
|
@klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
|
|
20
96
|
@transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
|
|
21
97
|
|
|
22
98
|
load_or_create_data
|
|
23
|
-
|
|
99
|
+
update_dataframe
|
|
24
100
|
end
|
|
25
101
|
|
|
102
|
+
# Loads existing data from cache or creates new data structure.
|
|
103
|
+
# If cached data exists, loads from JSON file. Otherwise creates
|
|
104
|
+
# minimal structure and attempts to fetch overview from API.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
26
107
|
def load_or_create_data
|
|
27
108
|
if @data_path.exist?
|
|
28
109
|
@data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
|
|
29
110
|
else
|
|
111
|
+
# Create minimal data structure
|
|
30
112
|
create_data
|
|
113
|
+
|
|
114
|
+
# Try to fetch overview data, but don't fail if we can't
|
|
115
|
+
# This is optional metadata - we can work with just price data
|
|
31
116
|
update
|
|
117
|
+
|
|
118
|
+
# Save whatever data we have (even if overview fetch failed)
|
|
32
119
|
save_data
|
|
33
120
|
end
|
|
34
121
|
end
|
|
35
122
|
|
|
123
|
+
# Creates a new minimal data structure for the stock.
|
|
124
|
+
#
|
|
125
|
+
# @return [SQA::DataFrame::Data] The newly created data object
|
|
36
126
|
def create_data
|
|
37
|
-
@data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {
|
|
127
|
+
@data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {})
|
|
38
128
|
end
|
|
39
129
|
|
|
130
|
+
# Updates the stock's overview data from the API.
|
|
131
|
+
# Silently handles errors since overview data is optional.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
40
134
|
def update
|
|
41
|
-
|
|
135
|
+
begin
|
|
136
|
+
merge_overview
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
# Log warning but don't fail - overview data is optional
|
|
139
|
+
# Common causes: rate limits, network issues, API errors
|
|
140
|
+
warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
|
|
141
|
+
end
|
|
42
142
|
end
|
|
43
143
|
|
|
144
|
+
# Persists the stock's metadata to a JSON file.
|
|
145
|
+
#
|
|
146
|
+
# @return [Integer] Number of bytes written
|
|
44
147
|
def save_data
|
|
45
148
|
@data_path.write(@data.to_json)
|
|
46
149
|
end
|
|
47
150
|
|
|
151
|
+
# @!method ticker
|
|
152
|
+
# @return [String] The stock's ticker symbol
|
|
153
|
+
# @!method name
|
|
154
|
+
# @return [String, nil] The company name
|
|
155
|
+
# @!method exchange
|
|
156
|
+
# @return [String, nil] The exchange where the stock trades
|
|
157
|
+
# @!method source
|
|
158
|
+
# @return [Symbol] The data source (:alpha_vantage or :yahoo_finance)
|
|
159
|
+
# @!method indicators
|
|
160
|
+
# @return [Hash] Cached indicator values
|
|
161
|
+
# @!method indicators=(value)
|
|
162
|
+
# @param value [Hash] New indicator values
|
|
163
|
+
# @!method overview
|
|
164
|
+
# @return [Hash, nil] Company overview data from API
|
|
48
165
|
def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
|
|
49
166
|
|
|
50
|
-
|
|
167
|
+
# Updates the DataFrame with price data.
|
|
168
|
+
# Loads from cache if available, otherwise fetches from API.
|
|
169
|
+
# Applies migrations for old data formats and updates with recent data.
|
|
170
|
+
#
|
|
171
|
+
# @return [void]
|
|
172
|
+
# @raise [SQA::DataFetchError] If data cannot be fetched and no cache exists
|
|
173
|
+
def update_dataframe
|
|
51
174
|
if @df_path.exist?
|
|
52
175
|
# Load cached CSV - transformers already applied when data was first fetched
|
|
53
176
|
# Don't reapply them as columns are already in correct format
|
|
@@ -82,36 +205,111 @@ class SQA::Stock
|
|
|
82
205
|
@df.to_csv(@df_path) if migrated
|
|
83
206
|
else
|
|
84
207
|
# Fetch fresh data from source (applies transformers and mapping)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
208
|
+
begin
|
|
209
|
+
@df = @klass.recent(@ticker, full: true)
|
|
210
|
+
@df.to_csv(@df_path)
|
|
211
|
+
return
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
# If we can't fetch data, raise a more helpful error
|
|
214
|
+
raise SQA::DataFetchError.new(
|
|
215
|
+
"Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}",
|
|
216
|
+
original: e
|
|
217
|
+
)
|
|
218
|
+
end
|
|
88
219
|
end
|
|
89
220
|
|
|
90
221
|
update_dataframe_with_recent_data
|
|
91
222
|
end
|
|
92
223
|
|
|
224
|
+
# Fetches recent data from API and appends to existing DataFrame.
|
|
225
|
+
# Only called if should_update? returns true.
|
|
226
|
+
#
|
|
227
|
+
# @return [void]
|
|
93
228
|
def update_dataframe_with_recent_data
|
|
94
|
-
|
|
95
|
-
|
|
229
|
+
return unless should_update?
|
|
230
|
+
|
|
231
|
+
begin
|
|
232
|
+
# CSV is sorted ascending (oldest first, TA-Lib compatible), so .last gets the most recent date
|
|
233
|
+
from_date = Date.parse(@df["timestamp"].to_a.last)
|
|
234
|
+
df2 = @klass.recent(@ticker, from_date: from_date)
|
|
235
|
+
|
|
236
|
+
if df2 && (df2.size > 0)
|
|
237
|
+
# Use concat_and_deduplicate! to prevent duplicate timestamps and maintain ascending sort
|
|
238
|
+
@df.concat_and_deduplicate!(df2)
|
|
239
|
+
@df.to_csv(@df_path)
|
|
240
|
+
end
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
# Log warning but don't fail - we have cached data
|
|
243
|
+
# Common causes: rate limits, network issues, API errors
|
|
244
|
+
warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @deprecated Use {#update_dataframe} instead. Will be removed in v1.0.0
|
|
249
|
+
# @return [void]
|
|
250
|
+
def update_the_dataframe
|
|
251
|
+
warn "[SQA DEPRECATION] update_the_dataframe is deprecated; use update_dataframe instead" if $VERBOSE
|
|
252
|
+
update_dataframe
|
|
253
|
+
end
|
|
96
254
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
255
|
+
# Determines whether the DataFrame should be updated from the API.
|
|
256
|
+
# Returns false if lazy_update is enabled, API key is missing,
|
|
257
|
+
# or data is already current.
|
|
258
|
+
#
|
|
259
|
+
# @return [Boolean] true if update should proceed, false otherwise
|
|
260
|
+
def should_update?
|
|
261
|
+
# Don't update if we're in lazy update mode
|
|
262
|
+
return false if SQA.config.lazy_update
|
|
263
|
+
|
|
264
|
+
# Don't update if we don't have an API key (only relevant for Alpha Vantage)
|
|
265
|
+
if @source == :alpha_vantage
|
|
266
|
+
begin
|
|
267
|
+
SQA.av_api_key
|
|
268
|
+
rescue SQA::ConfigurationError
|
|
269
|
+
return false
|
|
270
|
+
end
|
|
100
271
|
end
|
|
272
|
+
|
|
273
|
+
# Don't update if CSV data is already current (last timestamp is today or later)
|
|
274
|
+
# This prevents unnecessary API calls when we already have today's data
|
|
275
|
+
if @df && @df.size > 0
|
|
276
|
+
begin
|
|
277
|
+
last_timestamp = Date.parse(@df["timestamp"].to_a.last)
|
|
278
|
+
return false if last_timestamp >= Date.today
|
|
279
|
+
rescue ArgumentError, Date::Error => e
|
|
280
|
+
# If we can't parse the date, assume we need to update
|
|
281
|
+
warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
true
|
|
101
286
|
end
|
|
102
287
|
|
|
288
|
+
# Returns a human-readable string representation of the stock.
|
|
289
|
+
#
|
|
290
|
+
# @return [String] Summary including ticker, data points count, and date range
|
|
291
|
+
#
|
|
292
|
+
# @example
|
|
293
|
+
# stock.to_s # => "aapl with 252 data points from 2023-01-03 to 2023-12-29"
|
|
103
294
|
def to_s
|
|
104
295
|
"#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
|
|
105
296
|
end
|
|
297
|
+
# Note: CSV data is stored in ascending chronological order (oldest to newest)
|
|
298
|
+
# This ensures compatibility with TA-Lib indicators which expect arrays in this order
|
|
106
299
|
alias_method :inspect, :to_s
|
|
107
300
|
|
|
301
|
+
# Fetches and merges company overview data from Alpha Vantage API.
|
|
302
|
+
# Converts API response keys to snake_case and appropriate data types.
|
|
303
|
+
#
|
|
304
|
+
# @return [Hash] The merged overview data
|
|
305
|
+
# @raise [ApiError] If the API returns an error response
|
|
108
306
|
def merge_overview
|
|
109
307
|
temp = JSON.parse(
|
|
110
|
-
|
|
308
|
+
self.class.connection.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
|
|
111
309
|
.to_hash[:body]
|
|
112
310
|
)
|
|
113
311
|
|
|
114
|
-
if temp.
|
|
312
|
+
if temp.key?("Information")
|
|
115
313
|
ApiError.raise(temp["Information"])
|
|
116
314
|
end
|
|
117
315
|
|
|
@@ -130,10 +328,20 @@ class SQA::Stock
|
|
|
130
328
|
## Class Methods
|
|
131
329
|
|
|
132
330
|
class << self
|
|
331
|
+
# Fetches top gainers, losers, and most actively traded stocks from Alpha Vantage.
|
|
332
|
+
# Results are cached after the first call.
|
|
333
|
+
#
|
|
334
|
+
# @return [Hashie::Mash] Object with top_gainers, top_losers, and most_actively_traded arrays
|
|
335
|
+
#
|
|
336
|
+
# @example
|
|
337
|
+
# top = SQA::Stock.top
|
|
338
|
+
# top.top_gainers.each { |stock| puts "#{stock.ticker}: +#{stock.change_percentage}%" }
|
|
339
|
+
# top.top_losers.first.ticker # => "XYZ"
|
|
340
|
+
#
|
|
133
341
|
def top
|
|
134
|
-
return
|
|
342
|
+
return @top if @top
|
|
135
343
|
|
|
136
|
-
a_hash = JSON.parse(
|
|
344
|
+
a_hash = JSON.parse(connection.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
|
|
137
345
|
|
|
138
346
|
mash = Hashie::Mash.new(a_hash)
|
|
139
347
|
|
|
@@ -154,7 +362,15 @@ class SQA::Stock
|
|
|
154
362
|
end
|
|
155
363
|
end
|
|
156
364
|
|
|
157
|
-
|
|
365
|
+
@top = mash
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Resets the cached top gainers/losers data.
|
|
369
|
+
# Useful for testing or forcing a refresh.
|
|
370
|
+
#
|
|
371
|
+
# @return [nil]
|
|
372
|
+
def reset_top!
|
|
373
|
+
@top = nil
|
|
158
374
|
end
|
|
159
375
|
end
|
|
160
376
|
end
|
data/lib/sqa/strategy.rb
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
# lib/sqa/strategy.rb
|
|
2
2
|
|
|
3
|
+
# Framework for managing and executing trading strategies.
|
|
4
|
+
# Strategies are pluggable modules that generate buy/sell/hold signals.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# strategy = SQA::Strategy.new
|
|
8
|
+
# strategy.add(SQA::Strategy::RSI)
|
|
9
|
+
# strategy.add(SQA::Strategy::MACD)
|
|
10
|
+
# signals = strategy.execute(vector) # => [:buy, :hold]
|
|
11
|
+
#
|
|
12
|
+
# @example Auto-loading strategies
|
|
13
|
+
# strategy = SQA::Strategy.new
|
|
14
|
+
# strategy.auto_load(except: [:random])
|
|
15
|
+
#
|
|
3
16
|
class SQA::Strategy
|
|
17
|
+
# @!attribute [rw] strategies
|
|
18
|
+
# @return [Array<Method>] Collection of strategy trade methods
|
|
4
19
|
attr_accessor :strategies
|
|
5
20
|
|
|
21
|
+
# Creates a new Strategy instance with an empty strategies collection.
|
|
6
22
|
def initialize
|
|
7
23
|
@strategies = []
|
|
8
24
|
end
|
|
9
25
|
|
|
26
|
+
# Adds a trading strategy to the collection.
|
|
27
|
+
# Strategies must be either a Class with a .trade method or a Method object.
|
|
28
|
+
#
|
|
29
|
+
# @param a_strategy [Class, Method] Strategy to add
|
|
30
|
+
# @return [Array<Method>] Updated strategies collection
|
|
31
|
+
# @raise [BadParameterError] If strategy is not a Class or Method
|
|
32
|
+
#
|
|
33
|
+
# @example Adding a class-based strategy
|
|
34
|
+
# strategy.add(SQA::Strategy::RSI)
|
|
35
|
+
#
|
|
36
|
+
# @example Adding a method directly
|
|
37
|
+
# strategy.add(MyModule.method(:custom_trade))
|
|
38
|
+
#
|
|
10
39
|
def add(a_strategy)
|
|
11
|
-
raise BadParameterError unless
|
|
40
|
+
raise BadParameterError unless a_strategy.is_a?(Class) || a_strategy.is_a?(Method)
|
|
12
41
|
|
|
13
|
-
a_proc = if
|
|
42
|
+
a_proc = if a_strategy.is_a?(Class)
|
|
14
43
|
a_strategy.method(:trade)
|
|
15
44
|
else
|
|
16
45
|
a_strategy
|
|
@@ -19,13 +48,34 @@ class SQA::Strategy
|
|
|
19
48
|
@strategies << a_proc
|
|
20
49
|
end
|
|
21
50
|
|
|
51
|
+
# Executes all registered strategies with the given data vector.
|
|
52
|
+
#
|
|
53
|
+
# @param v [OpenStruct] Data vector containing indicator values and prices
|
|
54
|
+
# @return [Array<Symbol>] Array of signals (:buy, :sell, or :hold) from each strategy
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# vector = OpenStruct.new(rsi: 25, prices: prices_array)
|
|
58
|
+
# signals = strategy.execute(vector) # => [:buy, :hold, :sell]
|
|
59
|
+
#
|
|
22
60
|
def execute(v)
|
|
23
61
|
result = []
|
|
24
|
-
#
|
|
62
|
+
# NOTE: Could be parallelized with Parallel gem for large strategy sets
|
|
25
63
|
@strategies.each { |signal| result << signal.call(v) }
|
|
26
64
|
result
|
|
27
65
|
end
|
|
28
66
|
|
|
67
|
+
# Auto-loads strategy files from the strategy directory.
|
|
68
|
+
#
|
|
69
|
+
# @param except [Array<Symbol>] Strategy names to exclude (default: [:common])
|
|
70
|
+
# @param only [Array<Symbol>] If provided, only load these strategies
|
|
71
|
+
# @return [nil]
|
|
72
|
+
#
|
|
73
|
+
# @example Load all except random
|
|
74
|
+
# strategy.auto_load(except: [:common, :random])
|
|
75
|
+
#
|
|
76
|
+
# @example Load only specific strategies
|
|
77
|
+
# strategy.auto_load(only: [:rsi, :macd])
|
|
78
|
+
#
|
|
29
79
|
def auto_load(except: [:common], only: [])
|
|
30
80
|
dir_path = Pathname.new(__dir__) + "strategy"
|
|
31
81
|
except = Array(except).map{|f| f.to_s.downcase}
|
|
@@ -47,9 +97,17 @@ class SQA::Strategy
|
|
|
47
97
|
nil
|
|
48
98
|
end
|
|
49
99
|
|
|
100
|
+
# Returns all available strategy classes in the SQA::Strategy namespace.
|
|
101
|
+
#
|
|
102
|
+
# @return [Array<Class>] Array of strategy classes
|
|
103
|
+
#
|
|
104
|
+
# @example
|
|
105
|
+
# SQA::Strategy.new.available
|
|
106
|
+
# # => [SQA::Strategy::RSI, SQA::Strategy::MACD, ...]
|
|
107
|
+
#
|
|
50
108
|
def available
|
|
51
109
|
ObjectSpace.each_object(Class).select { |klass|
|
|
52
|
-
klass.
|
|
110
|
+
klass.name&.start_with?("SQA::Strategy::")
|
|
53
111
|
}
|
|
54
112
|
end
|
|
55
113
|
end
|
data/lib/sqa/ticker.rb
CHANGED
|
@@ -1,68 +1,133 @@
|
|
|
1
1
|
# sqa/lib/sqa/ticker.rb
|
|
2
2
|
#
|
|
3
|
-
#
|
|
3
|
+
# Stock ticker symbol validation and lookup using the dumbstockapi.com service.
|
|
4
|
+
# Downloads and caches a CSV file containing ticker symbols, company names, and exchanges.
|
|
4
5
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
6
|
+
# @example Validating a ticker
|
|
7
|
+
# SQA::Ticker.valid?('AAPL') # => true
|
|
8
|
+
# SQA::Ticker.valid?('FAKE') # => false
|
|
7
9
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# @example Looking up ticker info
|
|
11
|
+
# info = SQA::Ticker.lookup('AAPL')
|
|
12
|
+
# info[:name] # => "Apple Inc"
|
|
13
|
+
# info[:exchange] # => "NASDAQ"
|
|
12
14
|
#
|
|
13
15
|
class SQA::Ticker
|
|
16
|
+
# @return [String] Prefix for downloaded CSV filenames
|
|
14
17
|
FILENAME_PREFIX = "dumbstockapi"
|
|
18
|
+
|
|
19
|
+
# @return [Faraday::Connection] Connection to dumbstockapi.com
|
|
15
20
|
CONNECTION = Faraday.new(url: "https://dumbstockapi.com")
|
|
16
|
-
@@data = {}
|
|
17
21
|
|
|
22
|
+
class << self
|
|
23
|
+
# Downloads ticker data from dumbstockapi.com and saves to data directory.
|
|
24
|
+
#
|
|
25
|
+
# @param country [String] Country code for ticker list (default: "US")
|
|
26
|
+
# @return [Integer] HTTP status code from the download request
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# SQA::Ticker.download("US") # => 200
|
|
30
|
+
#
|
|
31
|
+
def download(country="US")
|
|
32
|
+
response = CONNECTION.get("/stock?format=csv&countries=#{country.upcase}").to_hash
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
if 200 == response[:status]
|
|
35
|
+
filename = response[:response_headers]["content-disposition"].split('=').last.gsub('"','')
|
|
36
|
+
out_path = Pathname.new(SQA.config.data_dir) + filename
|
|
37
|
+
out_path.write response[:body]
|
|
38
|
+
end
|
|
21
39
|
|
|
22
|
-
|
|
23
|
-
filename = response[:response_headers]["content-disposition"].split('=').last.gsub('"','')
|
|
24
|
-
out_path = Pathname.new(SQA.config.data_dir) + filename
|
|
25
|
-
out_path.write response[:body]
|
|
40
|
+
response[:status]
|
|
26
41
|
end
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
# Loads ticker data from cached CSV or downloads if not available.
|
|
44
|
+
# Retries download up to 3 times if no cached file exists.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash{String => Hash}] Hash mapping ticker symbols to info hashes
|
|
47
|
+
def load
|
|
48
|
+
tries = 0
|
|
49
|
+
found = false
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
until(found || tries >= 3) do
|
|
52
|
+
files = Pathname.new(SQA.config.data_dir).children.select{|c| c.basename.to_s.start_with?(FILENAME_PREFIX)}.sort
|
|
53
|
+
if files.empty?
|
|
54
|
+
begin
|
|
55
|
+
download
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
warn "Warning: Could not download ticker list: #{e.message}" if $VERBOSE
|
|
58
|
+
end
|
|
59
|
+
tries += 1
|
|
60
|
+
else
|
|
61
|
+
found = true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
35
64
|
|
|
36
|
-
until(found || tries >= 3) do
|
|
37
|
-
files = Pathname.new(SQA.config.data_dir).children.select{|c| c.basename.to_s.start_with?(FILENAME_PREFIX)}.sort
|
|
38
65
|
if files.empty?
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else
|
|
42
|
-
found = true
|
|
66
|
+
warn "Warning: No ticker validation data available. Proceeding without validation." if $VERBOSE
|
|
67
|
+
return {}
|
|
43
68
|
end
|
|
44
|
-
end
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
load_from_csv files.last
|
|
49
|
-
end
|
|
70
|
+
load_from_csv files.last
|
|
71
|
+
end
|
|
50
72
|
|
|
73
|
+
# Loads ticker data from a specific CSV file.
|
|
74
|
+
#
|
|
75
|
+
# @param csv_path [Pathname, String] Path to CSV file
|
|
76
|
+
# @return [Hash{String => Hash}] Hash mapping ticker symbols to info hashes
|
|
77
|
+
def load_from_csv(csv_path)
|
|
78
|
+
@data ||= {}
|
|
79
|
+
CSV.foreach(csv_path, headers: true) do |row|
|
|
80
|
+
@data[row["ticker"]] = {
|
|
81
|
+
name: row["name"],
|
|
82
|
+
exchange: row["exchange"]
|
|
83
|
+
}
|
|
84
|
+
end
|
|
51
85
|
|
|
52
|
-
|
|
53
|
-
CSV.foreach(csv_path, headers: true) do |row|
|
|
54
|
-
@@data[row["ticker"]] = {
|
|
55
|
-
name: row["name"],
|
|
56
|
-
exchange: row["exchange"]
|
|
57
|
-
}
|
|
86
|
+
@data
|
|
58
87
|
end
|
|
59
88
|
|
|
60
|
-
|
|
61
|
-
|
|
89
|
+
# Returns the cached ticker data, loading it if necessary.
|
|
90
|
+
#
|
|
91
|
+
# @return [Hash{String => Hash}] Hash mapping ticker symbols to info hashes
|
|
92
|
+
def data
|
|
93
|
+
@data ||= {}
|
|
94
|
+
@data.empty? ? load : @data
|
|
95
|
+
end
|
|
62
96
|
|
|
97
|
+
# Looks up information for a specific ticker symbol.
|
|
98
|
+
#
|
|
99
|
+
# @param ticker [String, nil] Ticker symbol to look up
|
|
100
|
+
# @return [Hash, nil] Hash with :name and :exchange keys, or nil if not found
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# SQA::Ticker.lookup('AAPL') # => { name: "Apple Inc", exchange: "NASDAQ" }
|
|
104
|
+
# SQA::Ticker.lookup('FAKE') # => nil
|
|
105
|
+
#
|
|
106
|
+
def lookup(ticker)
|
|
107
|
+
return nil if ticker.nil? || ticker.to_s.empty?
|
|
108
|
+
data[ticker.to_s.upcase]
|
|
109
|
+
end
|
|
63
110
|
|
|
111
|
+
# Checks if a ticker symbol is valid (exists in the data).
|
|
112
|
+
#
|
|
113
|
+
# @param ticker [String, nil] Ticker symbol to validate
|
|
114
|
+
# @return [Boolean] true if ticker exists, false otherwise
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# SQA::Ticker.valid?('AAPL') # => true
|
|
118
|
+
# SQA::Ticker.valid?(nil) # => false
|
|
119
|
+
#
|
|
120
|
+
def valid?(ticker)
|
|
121
|
+
return false if ticker.nil? || ticker.to_s.empty?
|
|
122
|
+
data.key?(ticker.to_s.upcase)
|
|
123
|
+
end
|
|
64
124
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
125
|
+
# Resets the cached ticker data.
|
|
126
|
+
# Useful for testing to force a fresh load.
|
|
127
|
+
#
|
|
128
|
+
# @return [Hash] Empty hash
|
|
129
|
+
def reset!
|
|
130
|
+
@data = {}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
68
133
|
end
|
data/lib/sqa/version.rb
CHANGED