sqa 0.0.32 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +154 -1
- data/README.md +4 -0
- data/Rakefile +52 -10
- data/docs/advanced/index.md +1 -13
- data/docs/api/index.md +547 -61
- data/docs/api-reference/alphavantageapi.md +1057 -0
- data/docs/api-reference/apierror.md +31 -0
- data/docs/api-reference/index.md +221 -0
- data/docs/api-reference/notimplemented.md +27 -0
- data/docs/api-reference/sqa.md +267 -0
- data/docs/api-reference/sqa_backtest.md +171 -0
- data/docs/api-reference/sqa_backtest_results.md +530 -0
- data/docs/api-reference/sqa_badparametererror.md +13 -0
- data/docs/api-reference/sqa_config.md +538 -0
- data/docs/api-reference/sqa_configurationerror.md +13 -0
- data/docs/api-reference/sqa_datafetcherror.md +56 -0
- data/docs/api-reference/sqa_dataframe.md +779 -0
- data/docs/api-reference/sqa_dataframe_alphavantage.md +30 -0
- data/docs/api-reference/sqa_dataframe_data.md +325 -0
- data/docs/api-reference/sqa_dataframe_yahoofinance.md +25 -0
- data/docs/api-reference/sqa_ensemble.md +413 -0
- data/docs/api-reference/sqa_fpop.md +211 -0
- data/docs/api-reference/sqa_geneticprogram.md +325 -0
- data/docs/api-reference/sqa_geneticprogram_individual.md +114 -0
- data/docs/api-reference/sqa_marketregime.md +212 -0
- data/docs/api-reference/sqa_multitimeframe.md +227 -0
- data/docs/api-reference/sqa_patternmatcher.md +195 -0
- data/docs/api-reference/sqa_pluginmanager.md +55 -0
- data/docs/api-reference/sqa_portfolio.md +512 -0
- data/docs/api-reference/sqa_portfolio_position.md +220 -0
- data/docs/api-reference/sqa_portfolio_trade.md +332 -0
- data/docs/api-reference/sqa_portfoliooptimizer.md +248 -0
- data/docs/api-reference/sqa_riskmanager.md +388 -0
- data/docs/api-reference/sqa_seasonalanalyzer.md +121 -0
- data/docs/api-reference/sqa_sectoranalyzer.md +163 -0
- data/docs/api-reference/sqa_stock.md +661 -0
- data/docs/api-reference/sqa_strategy.md +178 -0
- data/docs/api-reference/sqa_strategy_bollingerbands.md +26 -0
- data/docs/api-reference/sqa_strategy_common.md +29 -0
- data/docs/api-reference/sqa_strategy_consensus.md +129 -0
- data/docs/api-reference/sqa_strategy_ema.md +41 -0
- data/docs/api-reference/sqa_strategy_kbs.md +154 -0
- data/docs/api-reference/sqa_strategy_macd.md +26 -0
- data/docs/api-reference/sqa_strategy_mp.md +41 -0
- data/docs/api-reference/sqa_strategy_mr.md +41 -0
- data/docs/api-reference/sqa_strategy_random.md +41 -0
- data/docs/api-reference/sqa_strategy_rsi.md +41 -0
- data/docs/api-reference/sqa_strategy_sma.md +41 -0
- data/docs/api-reference/sqa_strategy_stochastic.md +26 -0
- data/docs/api-reference/sqa_strategy_volumebreakout.md +26 -0
- data/docs/api-reference/sqa_strategygenerator.md +298 -0
- data/docs/api-reference/sqa_strategygenerator_pattern.md +264 -0
- data/docs/api-reference/sqa_strategygenerator_patterncontext.md +326 -0
- data/docs/api-reference/sqa_strategygenerator_profitablepoint.md +424 -0
- data/docs/api-reference/sqa_stream.md +256 -0
- data/docs/api-reference/sqa_ticker.md +175 -0
- data/docs/api-reference/string.md +135 -0
- data/docs/assets/images/advanced-workflow.svg +89 -0
- data/docs/assets/images/architecture.svg +107 -0
- data/docs/assets/images/data-flow.svg +138 -0
- data/docs/assets/images/getting-started-workflow.svg +88 -0
- data/docs/assets/images/strategy-flow.svg +78 -0
- data/docs/assets/images/system-architecture.svg +150 -0
- data/docs/concepts/index.md +292 -19
- data/docs/file_formats.md +250 -0
- data/docs/getting-started/index.md +1 -14
- data/docs/index.md +26 -23
- data/docs/llms.txt +109 -0
- data/docs/strategies/kbs.md +15 -14
- data/docs/strategy.md +381 -3
- data/docs/terms_of_use.md +1 -1
- data/examples/README.md +10 -0
- data/lib/api/alpha_vantage_api.rb +3 -7
- data/lib/sqa/backtest.rb +32 -0
- data/lib/sqa/config.rb +109 -28
- data/lib/sqa/data_frame/data.rb +13 -1
- data/lib/sqa/data_frame.rb +193 -26
- data/lib/sqa/errors.rb +79 -17
- data/lib/sqa/init.rb +70 -15
- data/lib/sqa/pattern_matcher.rb +4 -4
- data/lib/sqa/portfolio.rb +55 -1
- data/lib/sqa/sector_analyzer.rb +3 -11
- data/lib/sqa/stock.rb +180 -15
- data/lib/sqa/strategy.rb +62 -4
- data/lib/sqa/ticker.rb +106 -48
- data/lib/sqa/version.rb +1 -1
- data/lib/sqa.rb +4 -4
- data/mkdocs.yml +69 -81
- metadata +89 -21
- data/docs/README.md +0 -43
- data/examples/sinatra_app/Gemfile +0 -42
- data/examples/sinatra_app/Gemfile.lock +0 -268
- data/examples/sinatra_app/QUICKSTART.md +0 -169
- data/examples/sinatra_app/README.md +0 -471
- data/examples/sinatra_app/RUNNING_WITHOUT_TALIB.md +0 -90
- data/examples/sinatra_app/TROUBLESHOOTING.md +0 -95
- data/examples/sinatra_app/app.rb +0 -404
- data/examples/sinatra_app/config.ru +0 -5
- data/examples/sinatra_app/public/css/style.css +0 -723
- data/examples/sinatra_app/public/debug_macd.html +0 -82
- data/examples/sinatra_app/public/js/app.js +0 -107
- data/examples/sinatra_app/start.sh +0 -53
- data/examples/sinatra_app/views/analyze.erb +0 -306
- data/examples/sinatra_app/views/backtest.erb +0 -325
- data/examples/sinatra_app/views/dashboard.erb +0 -831
- data/examples/sinatra_app/views/error.erb +0 -58
- data/examples/sinatra_app/views/index.erb +0 -118
- data/examples/sinatra_app/views/layout.erb +0 -61
- data/examples/sinatra_app/views/portfolio.erb +0 -43
data/lib/sqa/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
|
|
@@ -52,6 +52,19 @@ class SQA::Portfolio
|
|
|
52
52
|
# @param price [Float] Price per share
|
|
53
53
|
# @param date [Date] Date of trade
|
|
54
54
|
# @return [Trade] The executed trade
|
|
55
|
+
#
|
|
56
|
+
# @example Buy 10 shares of AAPL
|
|
57
|
+
# portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
|
|
58
|
+
# trade = portfolio.buy('AAPL', shares: 10, price: 150.0)
|
|
59
|
+
# trade.action # => :buy
|
|
60
|
+
# trade.total # => 1500.0
|
|
61
|
+
# portfolio.cash # => 8499.0 (10_000 - 1500 - 1.0 commission)
|
|
62
|
+
#
|
|
63
|
+
# @example Buy multiple stocks
|
|
64
|
+
# portfolio.buy('AAPL', shares: 10, price: 150.0)
|
|
65
|
+
# portfolio.buy('MSFT', shares: 5, price: 300.0)
|
|
66
|
+
# portfolio.positions.size # => 2
|
|
67
|
+
#
|
|
55
68
|
def buy(ticker, shares:, price:, date: Date.today)
|
|
56
69
|
raise BadParameterError, "Shares must be positive" if shares <= 0
|
|
57
70
|
raise BadParameterError, "Price must be positive" if price <= 0
|
|
@@ -95,6 +108,19 @@ class SQA::Portfolio
|
|
|
95
108
|
# @param price [Float] Price per share
|
|
96
109
|
# @param date [Date] Date of trade
|
|
97
110
|
# @return [Trade] The executed trade
|
|
111
|
+
#
|
|
112
|
+
# @example Sell entire position for profit
|
|
113
|
+
# portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
|
|
114
|
+
# portfolio.buy('AAPL', shares: 10, price: 150.0)
|
|
115
|
+
# trade = portfolio.sell('AAPL', shares: 10, price: 160.0)
|
|
116
|
+
# trade.total # => 1600.0
|
|
117
|
+
# portfolio.cash # => 8498.0 + 1599.0 = 10097.0 (after commissions)
|
|
118
|
+
#
|
|
119
|
+
# @example Partial sale
|
|
120
|
+
# portfolio.buy('AAPL', shares: 100, price: 150.0)
|
|
121
|
+
# portfolio.sell('AAPL', shares: 50, price: 160.0) # Sell half
|
|
122
|
+
# portfolio.position('AAPL').shares # => 50
|
|
123
|
+
#
|
|
98
124
|
def sell(ticker, shares:, price:, date: Date.today)
|
|
99
125
|
raise BadParameterError, "Shares must be positive" if shares <= 0
|
|
100
126
|
raise BadParameterError, "Price must be positive" if price <= 0
|
|
@@ -145,6 +171,18 @@ class SQA::Portfolio
|
|
|
145
171
|
# Calculate total portfolio value
|
|
146
172
|
# @param current_prices [Hash] Hash of ticker => current_price
|
|
147
173
|
# @return [Float] Total portfolio value (cash + positions)
|
|
174
|
+
#
|
|
175
|
+
# @example Calculate portfolio value with current prices
|
|
176
|
+
# portfolio = SQA::Portfolio.new(initial_cash: 10_000)
|
|
177
|
+
# portfolio.buy('AAPL', shares: 10, price: 150.0)
|
|
178
|
+
# portfolio.buy('MSFT', shares: 5, price: 300.0)
|
|
179
|
+
#
|
|
180
|
+
# current_prices = { 'AAPL' => 160.0, 'MSFT' => 310.0 }
|
|
181
|
+
# portfolio.value(current_prices) # => 10_000 - 1500 - 1500 + 1600 + 1550 = 10_150
|
|
182
|
+
#
|
|
183
|
+
# @example Without current prices (uses avg_cost)
|
|
184
|
+
# portfolio.value # Uses purchase prices if no current prices provided
|
|
185
|
+
#
|
|
148
186
|
def value(current_prices = {})
|
|
149
187
|
positions_value = @positions.sum do |ticker, pos|
|
|
150
188
|
current_price = current_prices[ticker] || pos.avg_cost
|
|
@@ -186,6 +224,22 @@ class SQA::Portfolio
|
|
|
186
224
|
# Get summary statistics
|
|
187
225
|
# @param current_prices [Hash] Hash of ticker => current_price
|
|
188
226
|
# @return [Hash] Summary statistics
|
|
227
|
+
#
|
|
228
|
+
# @example Get portfolio performance summary
|
|
229
|
+
# portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
|
|
230
|
+
# portfolio.buy('AAPL', shares: 10, price: 150.0)
|
|
231
|
+
# portfolio.sell('AAPL', shares: 5, price: 160.0)
|
|
232
|
+
#
|
|
233
|
+
# summary = portfolio.summary({ 'AAPL' => 165.0 })
|
|
234
|
+
# summary[:initial_cash] # => 10_000.0
|
|
235
|
+
# summary[:current_cash] # => 8798.0
|
|
236
|
+
# summary[:positions_count] # => 1
|
|
237
|
+
# summary[:total_value] # => 9623.0
|
|
238
|
+
# summary[:profit_loss_percent] # => -3.77%
|
|
239
|
+
# summary[:total_trades] # => 2
|
|
240
|
+
# summary[:buy_trades] # => 1
|
|
241
|
+
# summary[:sell_trades] # => 1
|
|
242
|
+
#
|
|
189
243
|
def summary(current_prices = {})
|
|
190
244
|
{
|
|
191
245
|
initial_cash: @initial_cash,
|
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,12 +1,83 @@
|
|
|
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
|
|
@@ -25,9 +96,14 @@ class SQA::Stock
|
|
|
25
96
|
@transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
|
|
26
97
|
|
|
27
98
|
load_or_create_data
|
|
28
|
-
|
|
99
|
+
update_dataframe
|
|
29
100
|
end
|
|
30
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]
|
|
31
107
|
def load_or_create_data
|
|
32
108
|
if @data_path.exist?
|
|
33
109
|
@data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
|
|
@@ -44,27 +120,68 @@ class SQA::Stock
|
|
|
44
120
|
end
|
|
45
121
|
end
|
|
46
122
|
|
|
123
|
+
# Creates a new minimal data structure for the stock.
|
|
124
|
+
#
|
|
125
|
+
# @return [SQA::DataFrame::Data] The newly created data object
|
|
47
126
|
def create_data
|
|
48
|
-
@data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {
|
|
127
|
+
@data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: {})
|
|
49
128
|
end
|
|
50
129
|
|
|
130
|
+
# Updates the stock's overview data from the API.
|
|
131
|
+
# Silently handles errors since overview data is optional.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
#
|
|
135
|
+
# @example Update stock metadata from API
|
|
136
|
+
# stock = SQA::Stock.new(ticker: 'AAPL')
|
|
137
|
+
# stock.update # Fetches latest company overview data
|
|
138
|
+
# stock.data.overview['market_capitalization'] # => 2500000000000
|
|
139
|
+
# stock.data.overview['pe_ratio'] # => 28.5
|
|
140
|
+
#
|
|
141
|
+
# @example Update is safe if API fails
|
|
142
|
+
# stock.update # No error raised if API is unavailable
|
|
143
|
+
# # Warning logged but stock remains usable with cached data
|
|
144
|
+
#
|
|
51
145
|
def update
|
|
52
146
|
begin
|
|
53
147
|
merge_overview
|
|
54
|
-
rescue => e
|
|
148
|
+
rescue StandardError => e
|
|
55
149
|
# Log warning but don't fail - overview data is optional
|
|
56
150
|
# Common causes: rate limits, network issues, API errors
|
|
57
151
|
warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
|
|
58
152
|
end
|
|
59
153
|
end
|
|
60
154
|
|
|
155
|
+
# Persists the stock's metadata to a JSON file.
|
|
156
|
+
#
|
|
157
|
+
# @return [Integer] Number of bytes written
|
|
61
158
|
def save_data
|
|
62
159
|
@data_path.write(@data.to_json)
|
|
63
160
|
end
|
|
64
161
|
|
|
162
|
+
# @!method ticker
|
|
163
|
+
# @return [String] The stock's ticker symbol
|
|
164
|
+
# @!method name
|
|
165
|
+
# @return [String, nil] The company name
|
|
166
|
+
# @!method exchange
|
|
167
|
+
# @return [String, nil] The exchange where the stock trades
|
|
168
|
+
# @!method source
|
|
169
|
+
# @return [Symbol] The data source (:alpha_vantage or :yahoo_finance)
|
|
170
|
+
# @!method indicators
|
|
171
|
+
# @return [Hash] Cached indicator values
|
|
172
|
+
# @!method indicators=(value)
|
|
173
|
+
# @param value [Hash] New indicator values
|
|
174
|
+
# @!method overview
|
|
175
|
+
# @return [Hash, nil] Company overview data from API
|
|
65
176
|
def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
|
|
66
177
|
|
|
67
|
-
|
|
178
|
+
# Updates the DataFrame with price data.
|
|
179
|
+
# Loads from cache if available, otherwise fetches from API.
|
|
180
|
+
# Applies migrations for old data formats and updates with recent data.
|
|
181
|
+
#
|
|
182
|
+
# @return [void]
|
|
183
|
+
# @raise [SQA::DataFetchError] If data cannot be fetched and no cache exists
|
|
184
|
+
def update_dataframe
|
|
68
185
|
if @df_path.exist?
|
|
69
186
|
# Load cached CSV - transformers already applied when data was first fetched
|
|
70
187
|
# Don't reapply them as columns are already in correct format
|
|
@@ -103,15 +220,22 @@ class SQA::Stock
|
|
|
103
220
|
@df = @klass.recent(@ticker, full: true)
|
|
104
221
|
@df.to_csv(@df_path)
|
|
105
222
|
return
|
|
106
|
-
rescue => e
|
|
223
|
+
rescue StandardError => e
|
|
107
224
|
# If we can't fetch data, raise a more helpful error
|
|
108
|
-
raise
|
|
225
|
+
raise SQA::DataFetchError.new(
|
|
226
|
+
"Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}",
|
|
227
|
+
original: e
|
|
228
|
+
)
|
|
109
229
|
end
|
|
110
230
|
end
|
|
111
231
|
|
|
112
232
|
update_dataframe_with_recent_data
|
|
113
233
|
end
|
|
114
234
|
|
|
235
|
+
# Fetches recent data from API and appends to existing DataFrame.
|
|
236
|
+
# Only called if should_update? returns true.
|
|
237
|
+
#
|
|
238
|
+
# @return [void]
|
|
115
239
|
def update_dataframe_with_recent_data
|
|
116
240
|
return unless should_update?
|
|
117
241
|
|
|
@@ -125,13 +249,25 @@ class SQA::Stock
|
|
|
125
249
|
@df.concat_and_deduplicate!(df2)
|
|
126
250
|
@df.to_csv(@df_path)
|
|
127
251
|
end
|
|
128
|
-
rescue => e
|
|
252
|
+
rescue StandardError => e
|
|
129
253
|
# Log warning but don't fail - we have cached data
|
|
130
254
|
# Common causes: rate limits, network issues, API errors
|
|
131
255
|
warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
|
|
132
256
|
end
|
|
133
257
|
end
|
|
134
258
|
|
|
259
|
+
# @deprecated Use {#update_dataframe} instead. Will be removed in v1.0.0
|
|
260
|
+
# @return [void]
|
|
261
|
+
def update_the_dataframe
|
|
262
|
+
warn "[SQA DEPRECATION] update_the_dataframe is deprecated; use update_dataframe instead" if $VERBOSE
|
|
263
|
+
update_dataframe
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Determines whether the DataFrame should be updated from the API.
|
|
267
|
+
# Returns false if lazy_update is enabled, API key is missing,
|
|
268
|
+
# or data is already current.
|
|
269
|
+
#
|
|
270
|
+
# @return [Boolean] true if update should proceed, false otherwise
|
|
135
271
|
def should_update?
|
|
136
272
|
# Don't update if we're in lazy update mode
|
|
137
273
|
return false if SQA.config.lazy_update
|
|
@@ -140,7 +276,7 @@ class SQA::Stock
|
|
|
140
276
|
if @source == :alpha_vantage
|
|
141
277
|
begin
|
|
142
278
|
SQA.av_api_key
|
|
143
|
-
rescue
|
|
279
|
+
rescue SQA::ConfigurationError
|
|
144
280
|
return false
|
|
145
281
|
end
|
|
146
282
|
end
|
|
@@ -151,7 +287,7 @@ class SQA::Stock
|
|
|
151
287
|
begin
|
|
152
288
|
last_timestamp = Date.parse(@df["timestamp"].to_a.last)
|
|
153
289
|
return false if last_timestamp >= Date.today
|
|
154
|
-
rescue => e
|
|
290
|
+
rescue ArgumentError, Date::Error => e
|
|
155
291
|
# If we can't parse the date, assume we need to update
|
|
156
292
|
warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
|
|
157
293
|
end
|
|
@@ -160,6 +296,12 @@ class SQA::Stock
|
|
|
160
296
|
true
|
|
161
297
|
end
|
|
162
298
|
|
|
299
|
+
# Returns a human-readable string representation of the stock.
|
|
300
|
+
#
|
|
301
|
+
# @return [String] Summary including ticker, data points count, and date range
|
|
302
|
+
#
|
|
303
|
+
# @example
|
|
304
|
+
# stock.to_s # => "aapl with 252 data points from 2023-01-03 to 2023-12-29"
|
|
163
305
|
def to_s
|
|
164
306
|
"#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
|
|
165
307
|
end
|
|
@@ -167,13 +309,18 @@ class SQA::Stock
|
|
|
167
309
|
# This ensures compatibility with TA-Lib indicators which expect arrays in this order
|
|
168
310
|
alias_method :inspect, :to_s
|
|
169
311
|
|
|
312
|
+
# Fetches and merges company overview data from Alpha Vantage API.
|
|
313
|
+
# Converts API response keys to snake_case and appropriate data types.
|
|
314
|
+
#
|
|
315
|
+
# @return [Hash] The merged overview data
|
|
316
|
+
# @raise [ApiError] If the API returns an error response
|
|
170
317
|
def merge_overview
|
|
171
318
|
temp = JSON.parse(
|
|
172
|
-
|
|
319
|
+
self.class.connection.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
|
|
173
320
|
.to_hash[:body]
|
|
174
321
|
)
|
|
175
322
|
|
|
176
|
-
if temp.
|
|
323
|
+
if temp.key?("Information")
|
|
177
324
|
ApiError.raise(temp["Information"])
|
|
178
325
|
end
|
|
179
326
|
|
|
@@ -192,10 +339,20 @@ class SQA::Stock
|
|
|
192
339
|
## Class Methods
|
|
193
340
|
|
|
194
341
|
class << self
|
|
342
|
+
# Fetches top gainers, losers, and most actively traded stocks from Alpha Vantage.
|
|
343
|
+
# Results are cached after the first call.
|
|
344
|
+
#
|
|
345
|
+
# @return [Hashie::Mash] Object with top_gainers, top_losers, and most_actively_traded arrays
|
|
346
|
+
#
|
|
347
|
+
# @example
|
|
348
|
+
# top = SQA::Stock.top
|
|
349
|
+
# top.top_gainers.each { |stock| puts "#{stock.ticker}: +#{stock.change_percentage}%" }
|
|
350
|
+
# top.top_losers.first.ticker # => "XYZ"
|
|
351
|
+
#
|
|
195
352
|
def top
|
|
196
|
-
return
|
|
353
|
+
return @top if @top
|
|
197
354
|
|
|
198
|
-
a_hash = JSON.parse(
|
|
355
|
+
a_hash = JSON.parse(connection.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
|
|
199
356
|
|
|
200
357
|
mash = Hashie::Mash.new(a_hash)
|
|
201
358
|
|
|
@@ -216,7 +373,15 @@ class SQA::Stock
|
|
|
216
373
|
end
|
|
217
374
|
end
|
|
218
375
|
|
|
219
|
-
|
|
376
|
+
@top = mash
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Resets the cached top gainers/losers data.
|
|
380
|
+
# Useful for testing or forcing a refresh.
|
|
381
|
+
#
|
|
382
|
+
# @return [nil]
|
|
383
|
+
def reset_top!
|
|
384
|
+
@top = nil
|
|
220
385
|
end
|
|
221
386
|
end
|
|
222
387
|
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
|