sqa 0.0.24 → 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 +82 -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 +812 -262
- 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/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/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/data_frame/alpha_vantage.rb +43 -65
- data/lib/sqa/data_frame/data.rb +92 -0
- data/lib/sqa/data_frame/yahoo_finance.rb +35 -43
- 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 +15 -8
- 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/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 -125
- data/lib/sqa/strategy/bollinger_bands.rb +42 -0
- 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 +23 -16
- data/main.just +81 -0
- data/mkdocs.yml +288 -0
- data/trace.log +0 -0
- metadata +261 -51
- data/bin/sqa +0 -6
- data/lib/patches/dry-cli.rb +0 -228
- data/lib/sqa/activity.rb +0 -10
- data/lib/sqa/cli.rb +0 -62
- data/lib/sqa/commands/analysis.rb +0 -309
- data/lib/sqa/commands/base.rb +0 -139
- data/lib/sqa/commands/web.rb +0 -199
- data/lib/sqa/commands.rb +0 -22
- data/lib/sqa/constants.rb +0 -23
- data/lib/sqa/indicator/average_true_range.rb +0 -33
- 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
|
@@ -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,197 +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
|
-
# Holds the SQA::Strategy class name which seems to work
|
|
20
|
-
# the best for this stock.
|
|
21
|
-
attr_accessor :strategy # TODO: make part of the @data object
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def initialize(
|
|
25
|
-
ticker:,
|
|
26
|
-
source: :alpha_vantage
|
|
27
|
-
)
|
|
8
|
+
attr_accessor :data, :df, :klass, :transformers, :strategy
|
|
28
9
|
|
|
29
|
-
|
|
30
|
-
@
|
|
10
|
+
def initialize(ticker:, source: :alpha_vantage)
|
|
11
|
+
@ticker = ticker.downcase
|
|
12
|
+
@source = source
|
|
31
13
|
|
|
32
14
|
raise "Invalid Ticker #{ticker}" unless SQA::Ticker.valid?(ticker)
|
|
33
15
|
|
|
34
|
-
@data_path
|
|
35
|
-
@df_path
|
|
16
|
+
@data_path = SQA.data_dir + "#{@ticker}.json"
|
|
17
|
+
@df_path = SQA.data_dir + "#{@ticker}.csv"
|
|
36
18
|
|
|
37
|
-
@klass
|
|
38
|
-
@transformers
|
|
19
|
+
@klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
|
|
20
|
+
@transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
|
|
39
21
|
|
|
22
|
+
load_or_create_data
|
|
23
|
+
update_the_dataframe
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load_or_create_data
|
|
40
27
|
if @data_path.exist?
|
|
41
|
-
|
|
28
|
+
@data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
|
|
42
29
|
else
|
|
43
|
-
|
|
30
|
+
create_data
|
|
44
31
|
update
|
|
45
|
-
|
|
32
|
+
save_data
|
|
46
33
|
end
|
|
47
|
-
|
|
48
|
-
update_the_dataframe
|
|
49
34
|
end
|
|
50
35
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@data = SQA::DataFrame::Data.new(
|
|
54
|
-
JSON.parse(@data_path.read)
|
|
55
|
-
)
|
|
36
|
+
def create_data
|
|
37
|
+
@data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: { xyzzy: "Magic" })
|
|
56
38
|
end
|
|
57
39
|
|
|
58
|
-
|
|
59
|
-
def create
|
|
60
|
-
@data =
|
|
61
|
-
SQA::DataFrame::Data.new(
|
|
62
|
-
{
|
|
63
|
-
ticker: @ticker,
|
|
64
|
-
source: @source,
|
|
65
|
-
indicators: { xyzzy: "Magic" },
|
|
66
|
-
}
|
|
67
|
-
)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
|
|
71
40
|
def update
|
|
72
41
|
merge_overview
|
|
73
42
|
end
|
|
74
43
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@data_path.write @data.to_json
|
|
44
|
+
def save_data
|
|
45
|
+
@data_path.write(@data.to_json)
|
|
78
46
|
end
|
|
79
47
|
|
|
80
|
-
|
|
81
|
-
def_delegator :@data, :ticker, :ticker
|
|
82
|
-
def_delegator :@data, :name, :name
|
|
83
|
-
def_delegator :@data, :exchange, :exchange
|
|
84
|
-
def_delegator :@data, :source, :source
|
|
85
|
-
def_delegator :@data, :indicators, :indicators
|
|
86
|
-
def_delegator :@data, :indicators=, :indicators=
|
|
87
|
-
def_delegator :@data, :overview, :overview
|
|
88
|
-
|
|
89
|
-
|
|
48
|
+
def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
|
|
90
49
|
|
|
91
50
|
def update_the_dataframe
|
|
92
51
|
if @df_path.exist?
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
97
83
|
else
|
|
98
|
-
|
|
84
|
+
# Fetch fresh data from source (applies transformers and mapping)
|
|
85
|
+
@df = @klass.recent(@ticker, full: true)
|
|
99
86
|
@df.to_csv(@df_path)
|
|
100
87
|
return
|
|
101
88
|
end
|
|
102
89
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return if df2.nil? # CSV file is up to date.
|
|
90
|
+
update_dataframe_with_recent_data
|
|
91
|
+
end
|
|
107
92
|
|
|
108
|
-
|
|
109
|
-
@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)
|
|
110
96
|
|
|
111
|
-
if
|
|
97
|
+
if df2 && (df2.size > 0)
|
|
98
|
+
@df.concat!(df2)
|
|
112
99
|
@df.to_csv(@df_path)
|
|
113
100
|
end
|
|
114
101
|
end
|
|
115
102
|
|
|
116
|
-
|
|
117
103
|
def to_s
|
|
118
|
-
"#{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}"
|
|
119
105
|
end
|
|
120
106
|
alias_method :inspect, :to_s
|
|
121
107
|
|
|
122
|
-
|
|
123
108
|
def merge_overview
|
|
124
109
|
temp = JSON.parse(
|
|
125
110
|
CONNECTION.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
|
|
126
|
-
|
|
111
|
+
.to_hash[:body]
|
|
127
112
|
)
|
|
128
113
|
|
|
129
114
|
if temp.has_key? "Information"
|
|
130
115
|
ApiError.raise(temp["Information"])
|
|
131
116
|
end
|
|
132
117
|
|
|
133
|
-
# TODO: CamelCase hash keys look common in Alpha Vantage
|
|
134
|
-
# JSON; look at making a special Hashie-based class
|
|
135
|
-
# to convert the keys to normal Ruby standards.
|
|
136
|
-
|
|
137
118
|
temp2 = {}
|
|
138
|
-
|
|
139
|
-
string_values = %w[ address asset_type cik country currency
|
|
140
|
-
description dividend_date ex_dividend_date
|
|
141
|
-
exchange fiscal_year_end industry latest_quarter
|
|
142
|
-
name sector symbol
|
|
143
|
-
]
|
|
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]
|
|
144
120
|
|
|
145
121
|
temp.keys.each do |k|
|
|
146
|
-
new_k
|
|
147
|
-
temp2[new_k]
|
|
122
|
+
new_k = k.underscore
|
|
123
|
+
temp2[new_k] = string_values.include?(new_k) ? temp[k] : temp[k].to_f
|
|
148
124
|
end
|
|
149
125
|
|
|
150
126
|
@data.overview = temp2
|
|
151
127
|
end
|
|
152
128
|
|
|
153
|
-
|
|
154
|
-
def associate_best_strategy(strategies)
|
|
155
|
-
best_strategy = nil
|
|
156
|
-
best_accuracy = 0
|
|
157
|
-
|
|
158
|
-
strategies.each do |strategy|
|
|
159
|
-
accuracy = evaluate_strategy(strategy)
|
|
160
|
-
|
|
161
|
-
if accuracy > best_accuracy
|
|
162
|
-
best_strategy = strategy
|
|
163
|
-
best_accuracy = accuracy
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
self.strategy = best_strategy
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def evaluate_strategy(strategy)
|
|
172
|
-
# TODO: Implement this method to evaluate the accuracy of the strategy
|
|
173
|
-
# on the historical data of this stock.
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
129
|
#############################################
|
|
179
130
|
## Class Methods
|
|
180
131
|
|
|
181
132
|
class << self
|
|
182
|
-
@@top = nil
|
|
183
|
-
|
|
184
|
-
# Top Gainers, Losers and Most Active for most
|
|
185
|
-
# recent closed trading day.
|
|
186
|
-
#
|
|
187
133
|
def top
|
|
188
134
|
return @@top unless @@top.nil?
|
|
189
135
|
|
|
190
|
-
a_hash
|
|
191
|
-
CONNECTION.get(
|
|
192
|
-
"/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}"
|
|
193
|
-
).to_hash[:body]
|
|
194
|
-
)
|
|
136
|
+
a_hash = JSON.parse(CONNECTION.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
|
|
195
137
|
|
|
196
138
|
mash = Hashie::Mash.new(a_hash)
|
|
197
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
|
|
@@ -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
|