sqa 0.0.22 → 0.0.31
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/.goose/memory/development.txt +3 -0
- data/.semver +6 -0
- data/ARCHITECTURE.md +648 -0
- data/CHANGELOG.md +86 -0
- data/CLAUDE.md +653 -0
- data/COMMITS.md +196 -0
- data/DATAFRAME_ARCHITECTURE_REVIEW.md +421 -0
- data/NEXT-STEPS.md +154 -0
- data/README.md +833 -213
- data/TASKS.md +358 -0
- data/TEST_RESULTS.md +140 -0
- data/TODO.md +42 -0
- data/_notes.txt +25 -0
- data/bin/sqa-console +11 -0
- data/checksums/sqa-0.0.23.gem.sha512 +1 -0
- data/checksums/sqa-0.0.24.gem.sha512 +1 -0
- data/data/talk_talk.json +103284 -0
- data/develop_summary.md +313 -0
- data/docs/advanced/backtesting.md +206 -0
- data/docs/advanced/ensemble.md +68 -0
- data/docs/advanced/fpop.md +153 -0
- data/docs/advanced/index.md +112 -0
- data/docs/advanced/multi-timeframe.md +67 -0
- data/docs/advanced/pattern-matcher.md +75 -0
- data/docs/advanced/portfolio-optimizer.md +79 -0
- data/docs/advanced/portfolio.md +166 -0
- data/docs/advanced/risk-management.md +210 -0
- data/docs/advanced/strategy-generator.md +158 -0
- data/docs/advanced/streaming.md +209 -0
- data/docs/ai_and_ml.md +80 -0
- data/docs/api/dataframe.md +1115 -0
- data/docs/api/index.md +126 -0
- data/docs/assets/css/custom.css +88 -0
- data/docs/assets/js/mathjax.js +18 -0
- data/docs/concepts/index.md +68 -0
- data/docs/contributing/index.md +60 -0
- data/docs/data-sources/index.md +66 -0
- data/docs/data_frame.md +317 -97
- data/docs/factors_that_impact_price.md +26 -0
- data/docs/finviz.md +11 -0
- data/docs/fx_pro_bit.md +25 -0
- data/docs/genetic_programming.md +104 -0
- data/docs/getting-started/index.md +123 -0
- data/docs/getting-started/installation.md +229 -0
- data/docs/getting-started/quick-start.md +244 -0
- data/docs/i_gotta_an_idea.md +22 -0
- data/docs/index.md +163 -0
- data/docs/indicators/index.md +97 -0
- data/docs/indicators.md +110 -24
- data/docs/options.md +8 -0
- data/docs/strategies/bollinger-bands.md +146 -0
- data/docs/strategies/consensus.md +64 -0
- data/docs/strategies/custom.md +310 -0
- data/docs/strategies/ema.md +53 -0
- data/docs/strategies/index.md +92 -0
- data/docs/strategies/kbs.md +164 -0
- data/docs/strategies/macd.md +96 -0
- data/docs/strategies/market-profile.md +54 -0
- data/docs/strategies/mean-reversion.md +58 -0
- data/docs/strategies/rsi.md +95 -0
- data/docs/strategies/sma.md +55 -0
- data/docs/strategies/stochastic.md +63 -0
- data/docs/strategies/volume-breakout.md +54 -0
- data/docs/ta_lib.md +160 -0
- data/docs/tags.md +7 -0
- data/docs/true_strength_index.md +46 -0
- data/docs/weighted_moving_average.md +48 -0
- data/examples/README.md +354 -0
- data/examples/advanced_features_example.rb +350 -0
- data/examples/fpop_analysis_example.rb +191 -0
- data/examples/genetic_programming_example.rb +148 -0
- data/examples/kbs_strategy_example.rb +208 -0
- data/examples/pattern_context_example.rb +300 -0
- data/examples/rails_app/Gemfile +34 -0
- data/examples/rails_app/README.md +416 -0
- data/examples/rails_app/app/assets/javascripts/application.js +107 -0
- data/examples/rails_app/app/assets/stylesheets/application.css +659 -0
- data/examples/rails_app/app/controllers/analysis_controller.rb +11 -0
- data/examples/rails_app/app/controllers/api/v1/stocks_controller.rb +227 -0
- data/examples/rails_app/app/controllers/application_controller.rb +22 -0
- data/examples/rails_app/app/controllers/backtest_controller.rb +11 -0
- data/examples/rails_app/app/controllers/dashboard_controller.rb +21 -0
- data/examples/rails_app/app/controllers/portfolio_controller.rb +7 -0
- data/examples/rails_app/app/views/analysis/show.html.erb +209 -0
- data/examples/rails_app/app/views/backtest/show.html.erb +171 -0
- data/examples/rails_app/app/views/dashboard/index.html.erb +118 -0
- data/examples/rails_app/app/views/dashboard/show.html.erb +408 -0
- data/examples/rails_app/app/views/errors/show.html.erb +17 -0
- data/examples/rails_app/app/views/layouts/application.html.erb +60 -0
- data/examples/rails_app/app/views/portfolio/index.html.erb +33 -0
- data/examples/rails_app/bin/rails +6 -0
- data/examples/rails_app/config/application.rb +45 -0
- data/examples/rails_app/config/boot.rb +5 -0
- data/examples/rails_app/config/database.yml +18 -0
- data/examples/rails_app/config/environment.rb +11 -0
- data/examples/rails_app/config/routes.rb +26 -0
- data/examples/rails_app/config.ru +8 -0
- data/examples/realtime_stream_example.rb +274 -0
- data/examples/sinatra_app/Gemfile +22 -0
- data/examples/sinatra_app/QUICKSTART.md +159 -0
- data/examples/sinatra_app/README.md +461 -0
- data/examples/sinatra_app/app.rb +344 -0
- data/examples/sinatra_app/config.ru +5 -0
- data/examples/sinatra_app/public/css/style.css +659 -0
- data/examples/sinatra_app/public/js/app.js +107 -0
- data/examples/sinatra_app/views/analyze.erb +306 -0
- data/examples/sinatra_app/views/backtest.erb +325 -0
- data/examples/sinatra_app/views/dashboard.erb +419 -0
- data/examples/sinatra_app/views/error.erb +58 -0
- data/examples/sinatra_app/views/index.erb +118 -0
- data/examples/sinatra_app/views/layout.erb +61 -0
- data/examples/sinatra_app/views/portfolio.erb +43 -0
- data/examples/strategy_generator_example.rb +346 -0
- data/hsa_portfolio.csv +11 -0
- data/justfile +0 -0
- data/lib/api/alpha_vantage_api.rb +462 -0
- data/lib/sqa/backtest.rb +329 -0
- data/lib/sqa/config.rb +22 -9
- data/lib/sqa/data_frame/alpha_vantage.rb +43 -65
- data/lib/sqa/data_frame/data.rb +92 -0
- data/lib/sqa/data_frame/yahoo_finance.rb +34 -41
- data/lib/sqa/data_frame.rb +148 -243
- data/lib/sqa/ensemble.rb +359 -0
- data/lib/sqa/fpop.rb +199 -0
- data/lib/sqa/gp.rb +259 -0
- data/lib/sqa/indicator.rb +5 -8
- data/lib/sqa/init.rb +16 -9
- data/lib/sqa/market_regime.rb +240 -0
- data/lib/sqa/multi_timeframe.rb +379 -0
- data/lib/sqa/pattern_matcher.rb +497 -0
- data/lib/sqa/plugin_manager.rb +20 -0
- data/lib/sqa/portfolio.rb +260 -6
- data/lib/sqa/portfolio_optimizer.rb +377 -0
- data/lib/sqa/risk_manager.rb +442 -0
- data/lib/sqa/seasonal_analyzer.rb +209 -0
- data/lib/sqa/sector_analyzer.rb +300 -0
- data/lib/sqa/stock.rb +67 -96
- data/lib/sqa/strategy/bollinger_bands.rb +42 -0
- data/lib/sqa/strategy/common.rb +0 -2
- data/lib/sqa/strategy/consensus.rb +5 -2
- data/lib/sqa/strategy/kbs_strategy.rb +470 -0
- data/lib/sqa/strategy/macd.rb +46 -0
- data/lib/sqa/strategy/mp.rb +1 -1
- data/lib/sqa/strategy/stochastic.rb +60 -0
- data/lib/sqa/strategy/volume_breakout.rb +57 -0
- data/lib/sqa/strategy.rb +5 -0
- data/lib/sqa/strategy_generator.rb +947 -0
- data/lib/sqa/stream.rb +361 -0
- data/lib/sqa/version.rb +1 -7
- data/lib/sqa.rb +41 -14
- data/main.just +81 -0
- data/mkdocs.yml +288 -0
- data/trace.log +0 -0
- metadata +279 -48
- data/bin/sqa +0 -6
- data/lib/sqa/activity.rb +0 -10
- data/lib/sqa/analysis.rb +0 -306
- data/lib/sqa/cli.rb +0 -173
- data/lib/sqa/constants.rb +0 -23
- data/lib/sqa/indicator/average_true_range.rb +0 -43
- data/lib/sqa/indicator/bollinger_bands.rb +0 -28
- data/lib/sqa/indicator/candlestick_pattern_recognizer.rb +0 -60
- data/lib/sqa/indicator/donchian_channel.rb +0 -29
- data/lib/sqa/indicator/double_top_bottom_pattern.rb +0 -34
- data/lib/sqa/indicator/elliott_wave_theory.rb +0 -57
- data/lib/sqa/indicator/exponential_moving_average.rb +0 -25
- data/lib/sqa/indicator/exponential_moving_average_trend.rb +0 -36
- data/lib/sqa/indicator/fibonacci_retracement.rb +0 -23
- data/lib/sqa/indicator/head_and_shoulders_pattern.rb +0 -26
- data/lib/sqa/indicator/market_profile.rb +0 -32
- data/lib/sqa/indicator/mean_reversion.rb +0 -37
- data/lib/sqa/indicator/momentum.rb +0 -28
- data/lib/sqa/indicator/moving_average_convergence_divergence.rb +0 -29
- data/lib/sqa/indicator/peaks_and_valleys.rb +0 -29
- data/lib/sqa/indicator/predict_next_value.rb +0 -202
- data/lib/sqa/indicator/relative_strength_index.rb +0 -47
- data/lib/sqa/indicator/simple_moving_average.rb +0 -24
- data/lib/sqa/indicator/simple_moving_average_trend.rb +0 -32
- data/lib/sqa/indicator/stochastic_oscillator.rb +0 -68
- data/lib/sqa/indicator/true_range.rb +0 -39
- data/lib/sqa/trade.rb +0 -26
- data/lib/sqa/web.rb +0 -159
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kbs/blackboard'
|
|
4
|
+
|
|
5
|
+
# SQA::SectorAnalyzer - Sector-based analysis with KBS blackboards
|
|
6
|
+
#
|
|
7
|
+
# Uses separate KBS blackboards for each stock sector to:
|
|
8
|
+
# - Track sector-wide patterns
|
|
9
|
+
# - Detect sector regime (bull/bear/rotation)
|
|
10
|
+
# - Analyze cross-stock correlations
|
|
11
|
+
# - Share pattern discoveries across sector
|
|
12
|
+
#
|
|
13
|
+
# Key Assumption: Stocks in the same sector tend to move together
|
|
14
|
+
#
|
|
15
|
+
# Example:
|
|
16
|
+
# analyzer = SQA::SectorAnalyzer.new
|
|
17
|
+
# analyzer.add_stock('AAPL', sector: :technology)
|
|
18
|
+
# analyzer.add_stock('MSFT', sector: :technology)
|
|
19
|
+
#
|
|
20
|
+
# # Discover patterns for entire tech sector
|
|
21
|
+
# patterns = analyzer.discover_sector_patterns(:technology)
|
|
22
|
+
#
|
|
23
|
+
# Sectors: :technology, :finance, :healthcare, :energy, :consumer, :industrial
|
|
24
|
+
|
|
25
|
+
module SQA
|
|
26
|
+
class SectorAnalyzer
|
|
27
|
+
SECTORS = {
|
|
28
|
+
technology: %w[AAPL MSFT GOOGL NVDA AMD INTC],
|
|
29
|
+
finance: %w[JPM BAC GS MS C WFC],
|
|
30
|
+
healthcare: %w[JNJ UNH PFE ABBV TMO MRK],
|
|
31
|
+
energy: %w[XOM CVX COP SLB EOG MPC],
|
|
32
|
+
consumer: %w[AMZN TSLA HD WMT NKE MCD],
|
|
33
|
+
industrial: %w[CAT DE BA MMM HON UPS],
|
|
34
|
+
materials: %w[LIN APD SHW FCX NEM DD],
|
|
35
|
+
utilities: %w[NEE DUK SO D AEP EXC],
|
|
36
|
+
real_estate: %w[AMT PLD CCI EQIX SPG O],
|
|
37
|
+
communications: %w[META NFLX DIS CMCSA T VZ]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
attr_reader :blackboards, :stocks_by_sector
|
|
41
|
+
|
|
42
|
+
def initialize(db_dir: '/tmp/sqa_sectors')
|
|
43
|
+
@blackboards = {}
|
|
44
|
+
@stocks_by_sector = Hash.new { |h, k| h[k] = [] }
|
|
45
|
+
@db_dir = db_dir
|
|
46
|
+
|
|
47
|
+
# Create directory for blackboard databases
|
|
48
|
+
require 'fileutils'
|
|
49
|
+
FileUtils.mkdir_p(@db_dir)
|
|
50
|
+
|
|
51
|
+
# Initialize blackboard for each sector
|
|
52
|
+
SECTORS.keys.each do |sector|
|
|
53
|
+
init_sector_blackboard(sector)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Add a stock to sector analysis
|
|
58
|
+
#
|
|
59
|
+
# @param stock [SQA::Stock, String] Stock object or ticker
|
|
60
|
+
# @param sector [Symbol] Sector classification
|
|
61
|
+
#
|
|
62
|
+
def add_stock(stock, sector:)
|
|
63
|
+
raise ArgumentError, "Unknown sector: #{sector}" unless SECTORS.key?(sector)
|
|
64
|
+
|
|
65
|
+
ticker = stock.is_a?(String) ? stock : stock.ticker
|
|
66
|
+
@stocks_by_sector[sector] << ticker unless @stocks_by_sector[sector].include?(ticker)
|
|
67
|
+
|
|
68
|
+
# Assert fact in sector blackboard
|
|
69
|
+
kb = @blackboards[sector]
|
|
70
|
+
kb.add_fact(:stock_registered, {
|
|
71
|
+
ticker: ticker,
|
|
72
|
+
sector: sector,
|
|
73
|
+
registered_at: Time.now.to_i
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
ticker
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Discover patterns for an entire sector
|
|
80
|
+
#
|
|
81
|
+
# @param sector [Symbol] Sector to analyze
|
|
82
|
+
# @param stocks [Array<SQA::Stock>] Stock objects to analyze
|
|
83
|
+
# @param options [Hash] Pattern discovery options
|
|
84
|
+
# @return [Array<Hash>] Sector-wide patterns
|
|
85
|
+
#
|
|
86
|
+
def discover_sector_patterns(sector, stocks, **options)
|
|
87
|
+
raise ArgumentError, "Unknown sector: #{sector}" unless SECTORS.key?(sector)
|
|
88
|
+
|
|
89
|
+
kb = @blackboards[sector]
|
|
90
|
+
all_patterns = []
|
|
91
|
+
|
|
92
|
+
puts "=" * 70
|
|
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
|
|
97
|
+
|
|
98
|
+
# Discover patterns for each stock
|
|
99
|
+
stocks.each do |stock|
|
|
100
|
+
puts "\nAnalyzing #{stock.ticker}..."
|
|
101
|
+
|
|
102
|
+
generator = SQA::StrategyGenerator.new(stock: stock, **options)
|
|
103
|
+
patterns = generator.discover_patterns
|
|
104
|
+
|
|
105
|
+
# Assert pattern facts in blackboard
|
|
106
|
+
patterns.each_with_index do |pattern, i|
|
|
107
|
+
kb.add_fact(:pattern_discovered, {
|
|
108
|
+
ticker: stock.ticker,
|
|
109
|
+
pattern_id: "#{stock.ticker}_#{i}",
|
|
110
|
+
conditions: pattern.conditions,
|
|
111
|
+
frequency: pattern.frequency,
|
|
112
|
+
avg_gain: pattern.avg_gain,
|
|
113
|
+
sector: sector
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
all_patterns << {
|
|
117
|
+
ticker: stock.ticker,
|
|
118
|
+
pattern: pattern,
|
|
119
|
+
sector: sector
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Detect sector-wide patterns (patterns that appear in multiple stocks)
|
|
125
|
+
sector_patterns = find_common_patterns(all_patterns)
|
|
126
|
+
|
|
127
|
+
# Assert sector-wide patterns
|
|
128
|
+
sector_patterns.each do |sp|
|
|
129
|
+
kb.add_fact(:sector_pattern, {
|
|
130
|
+
sector: sector,
|
|
131
|
+
conditions: sp[:conditions],
|
|
132
|
+
stock_count: sp[:stocks].size,
|
|
133
|
+
stocks: sp[:stocks],
|
|
134
|
+
avg_frequency: sp[:avg_frequency],
|
|
135
|
+
avg_gain: sp[:avg_gain]
|
|
136
|
+
})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
puts "\n" + "=" * 70
|
|
140
|
+
puts "Sector Analysis Complete"
|
|
141
|
+
puts " Individual patterns found: #{all_patterns.size}"
|
|
142
|
+
puts " Sector-wide patterns: #{sector_patterns.size}"
|
|
143
|
+
puts "=" * 70
|
|
144
|
+
|
|
145
|
+
sector_patterns
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Detect sector regime
|
|
149
|
+
#
|
|
150
|
+
# @param sector [Symbol] Sector to analyze
|
|
151
|
+
# @param stocks [Array<SQA::Stock>] Stock objects
|
|
152
|
+
# @return [Hash] Sector regime information
|
|
153
|
+
#
|
|
154
|
+
def detect_sector_regime(sector, stocks)
|
|
155
|
+
raise ArgumentError, "Unknown sector: #{sector}" unless SECTORS.key?(sector)
|
|
156
|
+
|
|
157
|
+
kb = @blackboards[sector]
|
|
158
|
+
|
|
159
|
+
# Detect regime for each stock
|
|
160
|
+
regimes = stocks.map do |stock|
|
|
161
|
+
SQA::MarketRegime.detect(stock)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Determine consensus regime
|
|
165
|
+
regime_counts = regimes.group_by { |r| r[:type] }.transform_values(&:size)
|
|
166
|
+
consensus_regime = regime_counts.max_by { |_k, v| v }&.first
|
|
167
|
+
|
|
168
|
+
# Calculate sector strength (% of stocks in bull regime)
|
|
169
|
+
bull_count = regimes.count { |r| r[:type] == :bull }
|
|
170
|
+
sector_strength = (bull_count.to_f / stocks.size * 100).round(2)
|
|
171
|
+
|
|
172
|
+
result = {
|
|
173
|
+
sector: sector,
|
|
174
|
+
consensus_regime: consensus_regime,
|
|
175
|
+
sector_strength: sector_strength,
|
|
176
|
+
stock_regimes: regimes,
|
|
177
|
+
detected_at: Time.now
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Assert sector regime fact
|
|
181
|
+
kb.add_fact(:sector_regime, {
|
|
182
|
+
sector: sector,
|
|
183
|
+
regime: consensus_regime,
|
|
184
|
+
strength: sector_strength,
|
|
185
|
+
timestamp: Time.now.to_i
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Query sector blackboard
|
|
192
|
+
#
|
|
193
|
+
# @param sector [Symbol] Sector to query
|
|
194
|
+
# @param fact_type [Symbol] Type of fact to query
|
|
195
|
+
# @param pattern [Hash] Pattern to match
|
|
196
|
+
# @return [Array<KBS::Fact>] Matching facts
|
|
197
|
+
#
|
|
198
|
+
def query_sector(sector, fact_type, pattern = {})
|
|
199
|
+
kb = @blackboards[sector]
|
|
200
|
+
kb.working_memory.facts.select do |fact|
|
|
201
|
+
next false unless fact.type == fact_type
|
|
202
|
+
pattern.all? { |key, value| fact.attributes[key] == value }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Print sector summary
|
|
207
|
+
#
|
|
208
|
+
# @param sector [Symbol] Sector to summarize
|
|
209
|
+
#
|
|
210
|
+
def print_sector_summary(sector)
|
|
211
|
+
kb = @blackboards[sector]
|
|
212
|
+
|
|
213
|
+
puts "\n" + "=" * 70
|
|
214
|
+
puts "#{sector.to_s.upcase} SECTOR SUMMARY"
|
|
215
|
+
puts "=" * 70
|
|
216
|
+
|
|
217
|
+
# Count facts by type
|
|
218
|
+
fact_counts = kb.working_memory.facts.group_by(&:type).transform_values(&:size)
|
|
219
|
+
|
|
220
|
+
puts "\nFacts in Blackboard:"
|
|
221
|
+
fact_counts.each do |type, count|
|
|
222
|
+
puts " #{type}: #{count}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Show sector regime if available
|
|
226
|
+
regime_facts = query_sector(sector, :sector_regime)
|
|
227
|
+
if regime_facts.any?
|
|
228
|
+
latest = regime_facts.last
|
|
229
|
+
puts "\nCurrent Sector Regime:"
|
|
230
|
+
puts " Type: #{latest[:regime]}"
|
|
231
|
+
puts " Strength: #{latest[:strength]}%"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Show sector patterns if available
|
|
235
|
+
pattern_facts = query_sector(sector, :sector_pattern)
|
|
236
|
+
if pattern_facts.any?
|
|
237
|
+
puts "\nSector-Wide Patterns: #{pattern_facts.size}"
|
|
238
|
+
pattern_facts.first(3).each_with_index do |fact, i|
|
|
239
|
+
puts " #{i + 1}. Conditions: #{fact[:conditions]}"
|
|
240
|
+
puts " Stocks: #{fact[:stocks].join(', ')}"
|
|
241
|
+
puts " Avg Gain: #{fact[:avg_gain].round(2)}%"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
puts "=" * 70
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# Initialize KBS blackboard for a sector
|
|
251
|
+
def init_sector_blackboard(sector)
|
|
252
|
+
db_path = File.join(@db_dir, "#{sector}.db")
|
|
253
|
+
|
|
254
|
+
# Create blackboard with persistent storage
|
|
255
|
+
@blackboards[sector] = KBS::Blackboard::Engine.new(db_path: db_path)
|
|
256
|
+
|
|
257
|
+
# Define sector-specific rules
|
|
258
|
+
define_sector_rules(sector)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Define rules for sector analysis
|
|
262
|
+
def define_sector_rules(sector)
|
|
263
|
+
kb = @blackboards[sector]
|
|
264
|
+
|
|
265
|
+
# Rule: Detect sector strength
|
|
266
|
+
rule = KBS::Rule.new("#{sector}_strength_detection") do |r|
|
|
267
|
+
r.conditions = [
|
|
268
|
+
KBS::Condition.new(:stock_registered, { sector: sector }),
|
|
269
|
+
KBS::Condition.new(:pattern_discovered, { sector: sector })
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
r.action = lambda do |facts, bindings|
|
|
273
|
+
# Could add logic here to assert derived facts
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
kb.add_rule(rule)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Find patterns common across multiple stocks
|
|
281
|
+
def find_common_patterns(all_patterns)
|
|
282
|
+
# Group by similar conditions
|
|
283
|
+
grouped = all_patterns.group_by do |p|
|
|
284
|
+
p[:pattern].conditions.sort.to_h
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Find groups with multiple stocks
|
|
288
|
+
common = grouped.select { |_conditions, group| group.size >= 2 }
|
|
289
|
+
|
|
290
|
+
common.map do |conditions, group|
|
|
291
|
+
{
|
|
292
|
+
conditions: conditions,
|
|
293
|
+
stocks: group.map { |p| p[:ticker] }.uniq,
|
|
294
|
+
avg_frequency: group.map { |p| p[:pattern].frequency }.sum / group.size.to_f,
|
|
295
|
+
avg_gain: group.map { |p| p[:pattern].avg_gain }.sum / group.size.to_f
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
data/lib/sqa/stock.rb
CHANGED
|
@@ -1,168 +1,139 @@
|
|
|
1
1
|
# lib/sqa/stock.rb
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# SMELL: SQA::Stock is now pretty coupled to the Alpha Vantage
|
|
5
|
-
# API service. Should that stuff be extracted into a
|
|
6
|
-
# separate class and injected by the requiring program?
|
|
7
|
-
|
|
8
3
|
class SQA::Stock
|
|
9
4
|
extend Forwardable
|
|
10
5
|
|
|
11
6
|
CONNECTION = Faraday.new(url: "https://www.alphavantage.co")
|
|
12
7
|
|
|
13
|
-
attr_accessor :data
|
|
14
|
-
attr_accessor :df # Historical Prices -- SQA::DataFrame::Data
|
|
15
|
-
|
|
16
|
-
attr_accessor :klass # class of historical and current prices
|
|
17
|
-
attr_accessor :transformers # procs for changing column values from String to Numeric
|
|
18
|
-
|
|
19
|
-
def initialize(
|
|
20
|
-
ticker:,
|
|
21
|
-
source: :alpha_vantage
|
|
22
|
-
)
|
|
8
|
+
attr_accessor :data, :df, :klass, :transformers, :strategy
|
|
23
9
|
|
|
24
|
-
|
|
25
|
-
@
|
|
10
|
+
def initialize(ticker:, source: :alpha_vantage)
|
|
11
|
+
@ticker = ticker.downcase
|
|
12
|
+
@source = source
|
|
26
13
|
|
|
27
14
|
raise "Invalid Ticker #{ticker}" unless SQA::Ticker.valid?(ticker)
|
|
28
15
|
|
|
29
|
-
@data_path
|
|
30
|
-
@df_path
|
|
16
|
+
@data_path = SQA.data_dir + "#{@ticker}.json"
|
|
17
|
+
@df_path = SQA.data_dir + "#{@ticker}.csv"
|
|
31
18
|
|
|
32
|
-
@klass
|
|
33
|
-
@transformers
|
|
19
|
+
@klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
|
|
20
|
+
@transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
|
|
34
21
|
|
|
22
|
+
load_or_create_data
|
|
23
|
+
update_the_dataframe
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load_or_create_data
|
|
35
27
|
if @data_path.exist?
|
|
36
|
-
|
|
28
|
+
@data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
|
|
37
29
|
else
|
|
38
|
-
|
|
30
|
+
create_data
|
|
39
31
|
update
|
|
40
|
-
|
|
32
|
+
save_data
|
|
41
33
|
end
|
|
42
|
-
|
|
43
|
-
update_the_dataframe
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def load
|
|
48
|
-
@data = SQA::DataFrame::Data.new(
|
|
49
|
-
JSON.parse(@data_path.read)
|
|
50
|
-
)
|
|
51
34
|
end
|
|
52
35
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@data =
|
|
56
|
-
SQA::DataFrame::Data.new(
|
|
57
|
-
{
|
|
58
|
-
ticker: @ticker,
|
|
59
|
-
source: @source,
|
|
60
|
-
indicators: { xyzzy: "Magic" },
|
|
61
|
-
}
|
|
62
|
-
)
|
|
36
|
+
def create_data
|
|
37
|
+
@data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: { xyzzy: "Magic" })
|
|
63
38
|
end
|
|
64
39
|
|
|
65
|
-
|
|
66
40
|
def update
|
|
67
41
|
merge_overview
|
|
68
42
|
end
|
|
69
43
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@data_path.write @data.to_json
|
|
44
|
+
def save_data
|
|
45
|
+
@data_path.write(@data.to_json)
|
|
73
46
|
end
|
|
74
47
|
|
|
75
|
-
|
|
76
|
-
def_delegator :@data, :ticker, :ticker
|
|
77
|
-
def_delegator :@data, :name, :name
|
|
78
|
-
def_delegator :@data, :exchange, :exchange
|
|
79
|
-
def_delegator :@data, :source, :source
|
|
80
|
-
def_delegator :@data, :indicators, :indicators
|
|
81
|
-
def_delegator :@data, :indicators=, :indicators=
|
|
82
|
-
def_delegator :@data, :overview, :overview
|
|
83
|
-
|
|
84
|
-
|
|
48
|
+
def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
|
|
85
49
|
|
|
86
50
|
def update_the_dataframe
|
|
87
51
|
if @df_path.exist?
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
52
|
+
# Load cached CSV - transformers already applied when data was first fetched
|
|
53
|
+
# Don't reapply them as columns are already in correct format
|
|
54
|
+
@df = SQA::DataFrame.load(source: @df_path)
|
|
55
|
+
|
|
56
|
+
migrated = false
|
|
57
|
+
|
|
58
|
+
# Migration 1: Rename old column names to new convention
|
|
59
|
+
# Old files may have: open, high, low, close
|
|
60
|
+
# New files should have: open_price, high_price, low_price, close_price
|
|
61
|
+
if @df.columns.include?("open") && !@df.columns.include?("open_price")
|
|
62
|
+
old_to_new_mapping = {
|
|
63
|
+
"open" => "open_price",
|
|
64
|
+
"high" => "high_price",
|
|
65
|
+
"low" => "low_price",
|
|
66
|
+
"close" => "close_price"
|
|
67
|
+
}
|
|
68
|
+
@df.rename_columns!(old_to_new_mapping)
|
|
69
|
+
migrated = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Migration 2: Add adj_close_price column if missing (for old cached files)
|
|
73
|
+
# This ensures compatibility when appending new data that includes this column
|
|
74
|
+
unless @df.columns.include?("adj_close_price")
|
|
75
|
+
@df.data = @df.data.with_column(
|
|
76
|
+
@df.data["close_price"].alias("adj_close_price")
|
|
77
|
+
)
|
|
78
|
+
migrated = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Save migrated DataFrame to avoid repeating migration
|
|
82
|
+
@df.to_csv(@df_path) if migrated
|
|
92
83
|
else
|
|
93
|
-
|
|
84
|
+
# Fetch fresh data from source (applies transformers and mapping)
|
|
85
|
+
@df = @klass.recent(@ticker, full: true)
|
|
94
86
|
@df.to_csv(@df_path)
|
|
95
87
|
return
|
|
96
88
|
end
|
|
97
89
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return if df2.nil? # CSV file is up to date.
|
|
90
|
+
update_dataframe_with_recent_data
|
|
91
|
+
end
|
|
102
92
|
|
|
103
|
-
|
|
104
|
-
@df.
|
|
93
|
+
def update_dataframe_with_recent_data
|
|
94
|
+
from_date = Date.parse(@df["timestamp"].to_a.last)
|
|
95
|
+
df2 = @klass.recent(@ticker, from_date: from_date)
|
|
105
96
|
|
|
106
|
-
if
|
|
97
|
+
if df2 && (df2.size > 0)
|
|
98
|
+
@df.concat!(df2)
|
|
107
99
|
@df.to_csv(@df_path)
|
|
108
100
|
end
|
|
109
101
|
end
|
|
110
102
|
|
|
111
|
-
|
|
112
103
|
def to_s
|
|
113
|
-
"#{ticker} with #{@df.size} data points from #{@df
|
|
104
|
+
"#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
|
|
114
105
|
end
|
|
115
106
|
alias_method :inspect, :to_s
|
|
116
107
|
|
|
117
|
-
|
|
118
108
|
def merge_overview
|
|
119
109
|
temp = JSON.parse(
|
|
120
110
|
CONNECTION.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
|
|
121
|
-
|
|
111
|
+
.to_hash[:body]
|
|
122
112
|
)
|
|
123
113
|
|
|
124
114
|
if temp.has_key? "Information"
|
|
125
115
|
ApiError.raise(temp["Information"])
|
|
126
116
|
end
|
|
127
117
|
|
|
128
|
-
# TODO: CamelCase hash keys look common in Alpha Vantage
|
|
129
|
-
# JSON; look at making a special Hashie-based class
|
|
130
|
-
# to convert the keys to normal Ruby standards.
|
|
131
|
-
|
|
132
118
|
temp2 = {}
|
|
133
|
-
|
|
134
|
-
string_values = %w[ address asset_type cik country currency
|
|
135
|
-
description dividend_date ex_dividend_date
|
|
136
|
-
exchange fiscal_year_end industry latest_quarter
|
|
137
|
-
name sector symbol
|
|
138
|
-
]
|
|
119
|
+
string_values = %w[address asset_type cik country currency description dividend_date ex_dividend_date exchange fiscal_year_end industry latest_quarter name sector symbol]
|
|
139
120
|
|
|
140
121
|
temp.keys.each do |k|
|
|
141
|
-
new_k
|
|
142
|
-
temp2[new_k]
|
|
122
|
+
new_k = k.underscore
|
|
123
|
+
temp2[new_k] = string_values.include?(new_k) ? temp[k] : temp[k].to_f
|
|
143
124
|
end
|
|
144
125
|
|
|
145
126
|
@data.overview = temp2
|
|
146
127
|
end
|
|
147
128
|
|
|
148
|
-
|
|
149
129
|
#############################################
|
|
150
130
|
## Class Methods
|
|
151
131
|
|
|
152
132
|
class << self
|
|
153
|
-
@@top = nil
|
|
154
|
-
|
|
155
|
-
# Top Gainers, Losers and Most Active for most
|
|
156
|
-
# recent closed trading day.
|
|
157
|
-
#
|
|
158
133
|
def top
|
|
159
134
|
return @@top unless @@top.nil?
|
|
160
135
|
|
|
161
|
-
a_hash
|
|
162
|
-
CONNECTION.get(
|
|
163
|
-
"/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}"
|
|
164
|
-
).to_hash[:body]
|
|
165
|
-
)
|
|
136
|
+
a_hash = JSON.parse(CONNECTION.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
|
|
166
137
|
|
|
167
138
|
mash = Hashie::Mash.new(a_hash)
|
|
168
139
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# lib/sqa/strategy/bollinger_bands.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Bollinger Bands trading strategy
|
|
5
|
+
# Buy when price touches lower band (oversold)
|
|
6
|
+
# Sell when price touches upper band (overbought)
|
|
7
|
+
#
|
|
8
|
+
class SQA::Strategy::BollingerBands
|
|
9
|
+
def self.trade(vector)
|
|
10
|
+
return :hold unless vector.respond_to?(:prices) && vector.prices&.size >= 20
|
|
11
|
+
|
|
12
|
+
prices = vector.prices
|
|
13
|
+
period = 20
|
|
14
|
+
std_dev = 2.0
|
|
15
|
+
|
|
16
|
+
# Calculate Bollinger Bands using SQAI
|
|
17
|
+
upper, middle, lower = SQAI.bbands(prices, period: period, nbdev_up: std_dev, nbdev_down: std_dev)
|
|
18
|
+
|
|
19
|
+
return :hold if upper.nil? || lower.nil?
|
|
20
|
+
|
|
21
|
+
current_price = prices.last
|
|
22
|
+
upper_band = upper.last
|
|
23
|
+
lower_band = lower.last
|
|
24
|
+
middle_band = middle.last
|
|
25
|
+
|
|
26
|
+
# Buy signal: price at or below lower band (oversold)
|
|
27
|
+
if current_price <= lower_band
|
|
28
|
+
:buy
|
|
29
|
+
|
|
30
|
+
# Sell signal: price at or above upper band (overbought)
|
|
31
|
+
elsif current_price >= upper_band
|
|
32
|
+
:sell
|
|
33
|
+
|
|
34
|
+
# Hold: price between bands
|
|
35
|
+
else
|
|
36
|
+
:hold
|
|
37
|
+
end
|
|
38
|
+
rescue => e
|
|
39
|
+
warn "BollingerBands strategy error: #{e.message}"
|
|
40
|
+
:hold
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/sqa/strategy/common.rb
CHANGED
|
@@ -26,9 +26,12 @@ class SQA::Strategy::Consensus
|
|
|
26
26
|
def consensus
|
|
27
27
|
count = @results.group_by(&:itself).transform_values(&:count)
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
buy_count = count[:buy].to_i
|
|
30
|
+
sell_count = count[:sell].to_i
|
|
31
|
+
|
|
32
|
+
if buy_count > sell_count
|
|
30
33
|
:buy
|
|
31
|
-
elsif
|
|
34
|
+
elsif sell_count > buy_count
|
|
32
35
|
:sell
|
|
33
36
|
else
|
|
34
37
|
:hold
|