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.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/.goose/memory/development.txt +3 -0
  3. data/.semver +6 -0
  4. data/ARCHITECTURE.md +648 -0
  5. data/CHANGELOG.md +82 -0
  6. data/CLAUDE.md +653 -0
  7. data/COMMITS.md +196 -0
  8. data/DATAFRAME_ARCHITECTURE_REVIEW.md +421 -0
  9. data/NEXT-STEPS.md +154 -0
  10. data/README.md +812 -262
  11. data/TASKS.md +358 -0
  12. data/TEST_RESULTS.md +140 -0
  13. data/TODO.md +42 -0
  14. data/_notes.txt +25 -0
  15. data/bin/sqa-console +11 -0
  16. data/data/talk_talk.json +103284 -0
  17. data/develop_summary.md +313 -0
  18. data/docs/advanced/backtesting.md +206 -0
  19. data/docs/advanced/ensemble.md +68 -0
  20. data/docs/advanced/fpop.md +153 -0
  21. data/docs/advanced/index.md +112 -0
  22. data/docs/advanced/multi-timeframe.md +67 -0
  23. data/docs/advanced/pattern-matcher.md +75 -0
  24. data/docs/advanced/portfolio-optimizer.md +79 -0
  25. data/docs/advanced/portfolio.md +166 -0
  26. data/docs/advanced/risk-management.md +210 -0
  27. data/docs/advanced/strategy-generator.md +158 -0
  28. data/docs/advanced/streaming.md +209 -0
  29. data/docs/ai_and_ml.md +80 -0
  30. data/docs/api/dataframe.md +1115 -0
  31. data/docs/api/index.md +126 -0
  32. data/docs/assets/css/custom.css +88 -0
  33. data/docs/assets/js/mathjax.js +18 -0
  34. data/docs/concepts/index.md +68 -0
  35. data/docs/contributing/index.md +60 -0
  36. data/docs/data-sources/index.md +66 -0
  37. data/docs/data_frame.md +317 -97
  38. data/docs/factors_that_impact_price.md +26 -0
  39. data/docs/finviz.md +11 -0
  40. data/docs/fx_pro_bit.md +25 -0
  41. data/docs/genetic_programming.md +104 -0
  42. data/docs/getting-started/index.md +123 -0
  43. data/docs/getting-started/installation.md +229 -0
  44. data/docs/getting-started/quick-start.md +244 -0
  45. data/docs/i_gotta_an_idea.md +22 -0
  46. data/docs/index.md +163 -0
  47. data/docs/indicators/index.md +97 -0
  48. data/docs/indicators.md +110 -24
  49. data/docs/options.md +8 -0
  50. data/docs/strategies/bollinger-bands.md +146 -0
  51. data/docs/strategies/consensus.md +64 -0
  52. data/docs/strategies/custom.md +310 -0
  53. data/docs/strategies/ema.md +53 -0
  54. data/docs/strategies/index.md +92 -0
  55. data/docs/strategies/kbs.md +164 -0
  56. data/docs/strategies/macd.md +96 -0
  57. data/docs/strategies/market-profile.md +54 -0
  58. data/docs/strategies/mean-reversion.md +58 -0
  59. data/docs/strategies/rsi.md +95 -0
  60. data/docs/strategies/sma.md +55 -0
  61. data/docs/strategies/stochastic.md +63 -0
  62. data/docs/strategies/volume-breakout.md +54 -0
  63. data/docs/tags.md +7 -0
  64. data/docs/true_strength_index.md +46 -0
  65. data/docs/weighted_moving_average.md +48 -0
  66. data/examples/README.md +354 -0
  67. data/examples/advanced_features_example.rb +350 -0
  68. data/examples/fpop_analysis_example.rb +191 -0
  69. data/examples/genetic_programming_example.rb +148 -0
  70. data/examples/kbs_strategy_example.rb +208 -0
  71. data/examples/pattern_context_example.rb +300 -0
  72. data/examples/rails_app/Gemfile +34 -0
  73. data/examples/rails_app/README.md +416 -0
  74. data/examples/rails_app/app/assets/javascripts/application.js +107 -0
  75. data/examples/rails_app/app/assets/stylesheets/application.css +659 -0
  76. data/examples/rails_app/app/controllers/analysis_controller.rb +11 -0
  77. data/examples/rails_app/app/controllers/api/v1/stocks_controller.rb +227 -0
  78. data/examples/rails_app/app/controllers/application_controller.rb +22 -0
  79. data/examples/rails_app/app/controllers/backtest_controller.rb +11 -0
  80. data/examples/rails_app/app/controllers/dashboard_controller.rb +21 -0
  81. data/examples/rails_app/app/controllers/portfolio_controller.rb +7 -0
  82. data/examples/rails_app/app/views/analysis/show.html.erb +209 -0
  83. data/examples/rails_app/app/views/backtest/show.html.erb +171 -0
  84. data/examples/rails_app/app/views/dashboard/index.html.erb +118 -0
  85. data/examples/rails_app/app/views/dashboard/show.html.erb +408 -0
  86. data/examples/rails_app/app/views/errors/show.html.erb +17 -0
  87. data/examples/rails_app/app/views/layouts/application.html.erb +60 -0
  88. data/examples/rails_app/app/views/portfolio/index.html.erb +33 -0
  89. data/examples/rails_app/bin/rails +6 -0
  90. data/examples/rails_app/config/application.rb +45 -0
  91. data/examples/rails_app/config/boot.rb +5 -0
  92. data/examples/rails_app/config/database.yml +18 -0
  93. data/examples/rails_app/config/environment.rb +11 -0
  94. data/examples/rails_app/config/routes.rb +26 -0
  95. data/examples/rails_app/config.ru +8 -0
  96. data/examples/realtime_stream_example.rb +274 -0
  97. data/examples/sinatra_app/Gemfile +22 -0
  98. data/examples/sinatra_app/QUICKSTART.md +159 -0
  99. data/examples/sinatra_app/README.md +461 -0
  100. data/examples/sinatra_app/app.rb +344 -0
  101. data/examples/sinatra_app/config.ru +5 -0
  102. data/examples/sinatra_app/public/css/style.css +659 -0
  103. data/examples/sinatra_app/public/js/app.js +107 -0
  104. data/examples/sinatra_app/views/analyze.erb +306 -0
  105. data/examples/sinatra_app/views/backtest.erb +325 -0
  106. data/examples/sinatra_app/views/dashboard.erb +419 -0
  107. data/examples/sinatra_app/views/error.erb +58 -0
  108. data/examples/sinatra_app/views/index.erb +118 -0
  109. data/examples/sinatra_app/views/layout.erb +61 -0
  110. data/examples/sinatra_app/views/portfolio.erb +43 -0
  111. data/examples/strategy_generator_example.rb +346 -0
  112. data/hsa_portfolio.csv +11 -0
  113. data/justfile +0 -0
  114. data/lib/api/alpha_vantage_api.rb +462 -0
  115. data/lib/sqa/backtest.rb +329 -0
  116. data/lib/sqa/data_frame/alpha_vantage.rb +43 -65
  117. data/lib/sqa/data_frame/data.rb +92 -0
  118. data/lib/sqa/data_frame/yahoo_finance.rb +35 -43
  119. data/lib/sqa/data_frame.rb +148 -243
  120. data/lib/sqa/ensemble.rb +359 -0
  121. data/lib/sqa/fpop.rb +199 -0
  122. data/lib/sqa/gp.rb +259 -0
  123. data/lib/sqa/indicator.rb +5 -8
  124. data/lib/sqa/init.rb +15 -8
  125. data/lib/sqa/market_regime.rb +240 -0
  126. data/lib/sqa/multi_timeframe.rb +379 -0
  127. data/lib/sqa/pattern_matcher.rb +497 -0
  128. data/lib/sqa/portfolio.rb +260 -6
  129. data/lib/sqa/portfolio_optimizer.rb +377 -0
  130. data/lib/sqa/risk_manager.rb +442 -0
  131. data/lib/sqa/seasonal_analyzer.rb +209 -0
  132. data/lib/sqa/sector_analyzer.rb +300 -0
  133. data/lib/sqa/stock.rb +67 -125
  134. data/lib/sqa/strategy/bollinger_bands.rb +42 -0
  135. data/lib/sqa/strategy/consensus.rb +5 -2
  136. data/lib/sqa/strategy/kbs_strategy.rb +470 -0
  137. data/lib/sqa/strategy/macd.rb +46 -0
  138. data/lib/sqa/strategy/mp.rb +1 -1
  139. data/lib/sqa/strategy/stochastic.rb +60 -0
  140. data/lib/sqa/strategy/volume_breakout.rb +57 -0
  141. data/lib/sqa/strategy.rb +5 -0
  142. data/lib/sqa/strategy_generator.rb +947 -0
  143. data/lib/sqa/stream.rb +361 -0
  144. data/lib/sqa/version.rb +1 -7
  145. data/lib/sqa.rb +23 -16
  146. data/main.just +81 -0
  147. data/mkdocs.yml +288 -0
  148. data/trace.log +0 -0
  149. metadata +261 -51
  150. data/bin/sqa +0 -6
  151. data/lib/patches/dry-cli.rb +0 -228
  152. data/lib/sqa/activity.rb +0 -10
  153. data/lib/sqa/cli.rb +0 -62
  154. data/lib/sqa/commands/analysis.rb +0 -309
  155. data/lib/sqa/commands/base.rb +0 -139
  156. data/lib/sqa/commands/web.rb +0 -199
  157. data/lib/sqa/commands.rb +0 -22
  158. data/lib/sqa/constants.rb +0 -23
  159. data/lib/sqa/indicator/average_true_range.rb +0 -33
  160. data/lib/sqa/indicator/bollinger_bands.rb +0 -28
  161. data/lib/sqa/indicator/candlestick_pattern_recognizer.rb +0 -60
  162. data/lib/sqa/indicator/donchian_channel.rb +0 -29
  163. data/lib/sqa/indicator/double_top_bottom_pattern.rb +0 -34
  164. data/lib/sqa/indicator/elliott_wave_theory.rb +0 -57
  165. data/lib/sqa/indicator/exponential_moving_average.rb +0 -25
  166. data/lib/sqa/indicator/exponential_moving_average_trend.rb +0 -36
  167. data/lib/sqa/indicator/fibonacci_retracement.rb +0 -23
  168. data/lib/sqa/indicator/head_and_shoulders_pattern.rb +0 -26
  169. data/lib/sqa/indicator/market_profile.rb +0 -32
  170. data/lib/sqa/indicator/mean_reversion.rb +0 -37
  171. data/lib/sqa/indicator/momentum.rb +0 -28
  172. data/lib/sqa/indicator/moving_average_convergence_divergence.rb +0 -29
  173. data/lib/sqa/indicator/peaks_and_valleys.rb +0 -29
  174. data/lib/sqa/indicator/predict_next_value.rb +0 -202
  175. data/lib/sqa/indicator/relative_strength_index.rb +0 -47
  176. data/lib/sqa/indicator/simple_moving_average.rb +0 -24
  177. data/lib/sqa/indicator/simple_moving_average_trend.rb +0 -32
  178. data/lib/sqa/indicator/stochastic_oscillator.rb +0 -68
  179. data/lib/sqa/indicator/true_range.rb +0 -39
  180. 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 # General Info -- SQA::DataFrame::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
- @ticker = ticker.downcase
30
- @source = source
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 = SQA.data_dir + "#{@ticker}.json"
35
- @df_path = SQA.data_dir + "#{@ticker}.csv"
16
+ @data_path = SQA.data_dir + "#{@ticker}.json"
17
+ @df_path = SQA.data_dir + "#{@ticker}.csv"
36
18
 
37
- @klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
38
- @transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
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
- load
28
+ @data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
42
29
  else
43
- create
30
+ create_data
44
31
  update
45
- save
32
+ save_data
46
33
  end
47
-
48
- update_the_dataframe
49
34
  end
50
35
 
51
-
52
- def load
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
- def save
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
- @df = SQA::DataFrame.load(
94
- source: @df_path,
95
- transformers: @transformers
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
- @df = klass.recent(@ticker, full: true)
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
- from_date = Date.parse(@df.timestamp.last) + 1
104
- df2 = klass.recent(@ticker, from_date: from_date)
105
-
106
- return if df2.nil? # CSV file is up to date.
90
+ update_dataframe_with_recent_data
91
+ end
107
92
 
108
- df_nrows = @df.nrows
109
- @df.append!(df2)
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 @df.nrows > df_nrows
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.timestamp.first} to #{@df.timestamp.last}"
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
- .to_hash[:body]
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 = k.underscore
147
- temp2[new_k] = string_values.include?(new_k) ? temp[k] : temp[k].to_f
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 = JSON.parse(
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
- if count[:buy] > count[:sell]
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 count[:sell] > count[:buy]
34
+ elsif sell_count > buy_count
32
35
  :sell
33
36
  else
34
37
  :hold