sqa 0.0.24 → 0.0.32

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 (203) 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 +95 -0
  6. data/CLAUDE.md +674 -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 +839 -265
  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 +1114 -0
  31. data/docs/api/index.md +126 -0
  32. data/docs/assets/css/custom.css +88 -0
  33. data/docs/assets/images/sqa.jpg +0 -0
  34. data/docs/assets/js/mathjax.js +18 -0
  35. data/docs/concepts/index.md +60 -0
  36. data/docs/contributing/index.md +60 -0
  37. data/docs/data-sources/index.md +66 -0
  38. data/docs/data_frame.md +316 -97
  39. data/docs/factors_that_impact_price.md +26 -0
  40. data/docs/finviz.md +11 -0
  41. data/docs/fx_pro_bit.md +25 -0
  42. data/docs/genetic_programming.md +104 -0
  43. data/docs/getting-started/index.md +107 -0
  44. data/docs/getting-started/installation.md +229 -0
  45. data/docs/getting-started/quick-start.md +244 -0
  46. data/docs/i_gotta_an_idea.md +22 -0
  47. data/docs/index.md +161 -0
  48. data/docs/indicators/index.md +97 -0
  49. data/docs/indicators.md +110 -24
  50. data/docs/options.md +8 -0
  51. data/docs/strategies/bollinger-bands.md +146 -0
  52. data/docs/strategies/consensus.md +64 -0
  53. data/docs/strategies/custom.md +310 -0
  54. data/docs/strategies/ema.md +53 -0
  55. data/docs/strategies/index.md +92 -0
  56. data/docs/strategies/kbs.md +164 -0
  57. data/docs/strategies/macd.md +96 -0
  58. data/docs/strategies/market-profile.md +54 -0
  59. data/docs/strategies/mean-reversion.md +58 -0
  60. data/docs/strategies/rsi.md +95 -0
  61. data/docs/strategies/sma.md +55 -0
  62. data/docs/strategies/stochastic.md +63 -0
  63. data/docs/strategies/volume-breakout.md +54 -0
  64. data/docs/tags.md +7 -0
  65. data/examples/README.md +354 -0
  66. data/examples/advanced_features_example.rb +350 -0
  67. data/examples/fpop_analysis_example.rb +191 -0
  68. data/examples/genetic_programming_example.rb +148 -0
  69. data/examples/kbs_strategy_example.rb +208 -0
  70. data/examples/pattern_context_example.rb +300 -0
  71. data/examples/rails_app/Gemfile +34 -0
  72. data/examples/rails_app/README.md +416 -0
  73. data/examples/rails_app/app/assets/javascripts/application.js +107 -0
  74. data/examples/rails_app/app/assets/stylesheets/application.css +659 -0
  75. data/examples/rails_app/app/controllers/analysis_controller.rb +11 -0
  76. data/examples/rails_app/app/controllers/api/v1/stocks_controller.rb +227 -0
  77. data/examples/rails_app/app/controllers/application_controller.rb +22 -0
  78. data/examples/rails_app/app/controllers/backtest_controller.rb +11 -0
  79. data/examples/rails_app/app/controllers/dashboard_controller.rb +21 -0
  80. data/examples/rails_app/app/controllers/portfolio_controller.rb +7 -0
  81. data/examples/rails_app/app/views/analysis/show.html.erb +209 -0
  82. data/examples/rails_app/app/views/backtest/show.html.erb +171 -0
  83. data/examples/rails_app/app/views/dashboard/index.html.erb +118 -0
  84. data/examples/rails_app/app/views/dashboard/show.html.erb +408 -0
  85. data/examples/rails_app/app/views/errors/show.html.erb +17 -0
  86. data/examples/rails_app/app/views/layouts/application.html.erb +60 -0
  87. data/examples/rails_app/app/views/portfolio/index.html.erb +33 -0
  88. data/examples/rails_app/bin/rails +6 -0
  89. data/examples/rails_app/config/application.rb +45 -0
  90. data/examples/rails_app/config/boot.rb +5 -0
  91. data/examples/rails_app/config/database.yml +18 -0
  92. data/examples/rails_app/config/environment.rb +11 -0
  93. data/examples/rails_app/config/routes.rb +26 -0
  94. data/examples/rails_app/config.ru +8 -0
  95. data/examples/realtime_stream_example.rb +274 -0
  96. data/examples/sinatra_app/Gemfile +42 -0
  97. data/examples/sinatra_app/Gemfile.lock +268 -0
  98. data/examples/sinatra_app/QUICKSTART.md +169 -0
  99. data/examples/sinatra_app/README.md +471 -0
  100. data/examples/sinatra_app/RUNNING_WITHOUT_TALIB.md +90 -0
  101. data/examples/sinatra_app/TROUBLESHOOTING.md +95 -0
  102. data/examples/sinatra_app/app.rb +404 -0
  103. data/examples/sinatra_app/config.ru +5 -0
  104. data/examples/sinatra_app/public/css/style.css +723 -0
  105. data/examples/sinatra_app/public/debug_macd.html +82 -0
  106. data/examples/sinatra_app/public/js/app.js +107 -0
  107. data/examples/sinatra_app/start.sh +53 -0
  108. data/examples/sinatra_app/views/analyze.erb +306 -0
  109. data/examples/sinatra_app/views/backtest.erb +325 -0
  110. data/examples/sinatra_app/views/dashboard.erb +831 -0
  111. data/examples/sinatra_app/views/error.erb +58 -0
  112. data/examples/sinatra_app/views/index.erb +118 -0
  113. data/examples/sinatra_app/views/layout.erb +61 -0
  114. data/examples/sinatra_app/views/portfolio.erb +43 -0
  115. data/examples/strategy_generator_example.rb +346 -0
  116. data/hsa_portfolio.csv +11 -0
  117. data/justfile +0 -0
  118. data/lib/api/alpha_vantage_api.rb +462 -0
  119. data/lib/sqa/backtest.rb +329 -0
  120. data/lib/sqa/data_frame/alpha_vantage.rb +51 -63
  121. data/lib/sqa/data_frame/data.rb +92 -0
  122. data/lib/sqa/data_frame/yahoo_finance.rb +35 -43
  123. data/lib/sqa/data_frame.rb +154 -243
  124. data/lib/sqa/ensemble.rb +359 -0
  125. data/lib/sqa/fpop.rb +199 -0
  126. data/lib/sqa/gp.rb +259 -0
  127. data/lib/sqa/indicator.rb +16 -6
  128. data/lib/sqa/init.rb +15 -8
  129. data/lib/sqa/market_regime.rb +240 -0
  130. data/lib/sqa/multi_timeframe.rb +379 -0
  131. data/lib/sqa/pattern_matcher.rb +497 -0
  132. data/lib/sqa/portfolio.rb +260 -6
  133. data/lib/sqa/portfolio_optimizer.rb +377 -0
  134. data/lib/sqa/risk_manager.rb +442 -0
  135. data/lib/sqa/seasonal_analyzer.rb +209 -0
  136. data/lib/sqa/sector_analyzer.rb +300 -0
  137. data/lib/sqa/stock.rb +131 -127
  138. data/lib/sqa/strategy/bollinger_bands.rb +42 -0
  139. data/lib/sqa/strategy/consensus.rb +5 -2
  140. data/lib/sqa/strategy/kbs_strategy.rb +470 -0
  141. data/lib/sqa/strategy/macd.rb +46 -0
  142. data/lib/sqa/strategy/mp.rb +1 -1
  143. data/lib/sqa/strategy/stochastic.rb +60 -0
  144. data/lib/sqa/strategy/volume_breakout.rb +57 -0
  145. data/lib/sqa/strategy.rb +5 -0
  146. data/lib/sqa/strategy_generator.rb +947 -0
  147. data/lib/sqa/stream.rb +361 -0
  148. data/lib/sqa/ticker.rb +9 -2
  149. data/lib/sqa/version.rb +1 -7
  150. data/lib/sqa.rb +35 -20
  151. data/main.just +81 -0
  152. data/mkdocs.yml +252 -0
  153. data/trace.log +0 -0
  154. metadata +265 -69
  155. data/bin/sqa +0 -6
  156. data/docs/alpha_vantage_technical_indicators.md +0 -62
  157. data/docs/average_true_range.md +0 -9
  158. data/docs/bollinger_bands.md +0 -15
  159. data/docs/candlestick_pattern_recognizer.md +0 -4
  160. data/docs/donchian_channel.md +0 -5
  161. data/docs/double_top_bottom_pattern.md +0 -3
  162. data/docs/exponential_moving_average.md +0 -19
  163. data/docs/fibonacci_retracement.md +0 -30
  164. data/docs/head_and_shoulders_pattern.md +0 -3
  165. data/docs/market_profile.md +0 -4
  166. data/docs/momentum.md +0 -19
  167. data/docs/moving_average_convergence_divergence.md +0 -23
  168. data/docs/peaks_and_valleys.md +0 -11
  169. data/docs/relative_strength_index.md +0 -6
  170. data/docs/simple_moving_average.md +0 -8
  171. data/docs/stochastic_oscillator.md +0 -4
  172. data/docs/ta_lib.md +0 -160
  173. data/docs/true_range.md +0 -12
  174. data/lib/patches/dry-cli.rb +0 -228
  175. data/lib/sqa/activity.rb +0 -10
  176. data/lib/sqa/cli.rb +0 -62
  177. data/lib/sqa/commands/analysis.rb +0 -309
  178. data/lib/sqa/commands/base.rb +0 -139
  179. data/lib/sqa/commands/web.rb +0 -199
  180. data/lib/sqa/commands.rb +0 -22
  181. data/lib/sqa/constants.rb +0 -23
  182. data/lib/sqa/indicator/average_true_range.rb +0 -33
  183. data/lib/sqa/indicator/bollinger_bands.rb +0 -28
  184. data/lib/sqa/indicator/candlestick_pattern_recognizer.rb +0 -60
  185. data/lib/sqa/indicator/donchian_channel.rb +0 -29
  186. data/lib/sqa/indicator/double_top_bottom_pattern.rb +0 -34
  187. data/lib/sqa/indicator/elliott_wave_theory.rb +0 -57
  188. data/lib/sqa/indicator/exponential_moving_average.rb +0 -25
  189. data/lib/sqa/indicator/exponential_moving_average_trend.rb +0 -36
  190. data/lib/sqa/indicator/fibonacci_retracement.rb +0 -23
  191. data/lib/sqa/indicator/head_and_shoulders_pattern.rb +0 -26
  192. data/lib/sqa/indicator/market_profile.rb +0 -32
  193. data/lib/sqa/indicator/mean_reversion.rb +0 -37
  194. data/lib/sqa/indicator/momentum.rb +0 -28
  195. data/lib/sqa/indicator/moving_average_convergence_divergence.rb +0 -29
  196. data/lib/sqa/indicator/peaks_and_valleys.rb +0 -29
  197. data/lib/sqa/indicator/predict_next_value.rb +0 -202
  198. data/lib/sqa/indicator/relative_strength_index.rb +0 -47
  199. data/lib/sqa/indicator/simple_moving_average.rb +0 -24
  200. data/lib/sqa/indicator/simple_moving_average_trend.rb +0 -32
  201. data/lib/sqa/indicator/stochastic_oscillator.rb +0 -68
  202. data/lib/sqa/indicator/true_range.rb +0 -39
  203. 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,201 @@
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
8
+ attr_accessor :data, :df, :klass, :transformers, :strategy
22
9
 
10
+ def initialize(ticker:, source: :alpha_vantage)
11
+ @ticker = ticker.downcase
12
+ @source = source
23
13
 
24
- def initialize(
25
- ticker:,
26
- source: :alpha_vantage
27
- )
14
+ @data_path = SQA.data_dir + "#{@ticker}.json"
15
+ @df_path = SQA.data_dir + "#{@ticker}.csv"
28
16
 
29
- @ticker = ticker.downcase
30
- @source = source
31
-
32
- raise "Invalid Ticker #{ticker}" unless SQA::Ticker.valid?(ticker)
17
+ # Validate ticker if validation data is available and cached data doesn't exist
18
+ unless @data_path.exist? && @df_path.exist?
19
+ unless SQA::Ticker.valid?(ticker)
20
+ warn "Warning: Ticker #{ticker} could not be validated. Proceeding anyway." if $VERBOSE
21
+ end
22
+ end
33
23
 
34
- @data_path = SQA.data_dir + "#{@ticker}.json"
35
- @df_path = SQA.data_dir + "#{@ticker}.csv"
24
+ @klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
25
+ @transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
36
26
 
37
- @klass = "SQA::DataFrame::#{@source.to_s.camelize}".constantize
38
- @transformers = "SQA::DataFrame::#{@source.to_s.camelize}::TRANSFORMERS".constantize
27
+ load_or_create_data
28
+ update_the_dataframe
29
+ end
39
30
 
31
+ def load_or_create_data
40
32
  if @data_path.exist?
41
- load
33
+ @data = SQA::DataFrame::Data.new(JSON.parse(@data_path.read))
42
34
  else
43
- create
44
- update
45
- save
46
- end
47
-
48
- update_the_dataframe
49
- end
35
+ # Create minimal data structure
36
+ create_data
50
37
 
38
+ # Try to fetch overview data, but don't fail if we can't
39
+ # This is optional metadata - we can work with just price data
40
+ update
51
41
 
52
- def load
53
- @data = SQA::DataFrame::Data.new(
54
- JSON.parse(@data_path.read)
55
- )
42
+ # Save whatever data we have (even if overview fetch failed)
43
+ save_data
44
+ end
56
45
  end
57
46
 
58
-
59
- def create
60
- @data =
61
- SQA::DataFrame::Data.new(
62
- {
63
- ticker: @ticker,
64
- source: @source,
65
- indicators: { xyzzy: "Magic" },
66
- }
67
- )
47
+ def create_data
48
+ @data = SQA::DataFrame::Data.new(ticker: @ticker, source: @source, indicators: { xyzzy: "Magic" })
68
49
  end
69
50
 
70
-
71
51
  def update
72
- merge_overview
52
+ begin
53
+ merge_overview
54
+ rescue => e
55
+ # Log warning but don't fail - overview data is optional
56
+ # Common causes: rate limits, network issues, API errors
57
+ warn "Warning: Could not fetch overview data for #{@ticker} (#{e.class}: #{e.message}). Continuing without it."
58
+ end
73
59
  end
74
60
 
75
-
76
- def save
77
- @data_path.write @data.to_json
61
+ def save_data
62
+ @data_path.write(@data.to_json)
78
63
  end
79
64
 
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
-
65
+ def_delegators :@data, :ticker, :name, :exchange, :source, :indicators, :indicators=, :overview
90
66
 
91
67
  def update_the_dataframe
92
68
  if @df_path.exist?
93
- @df = SQA::DataFrame.load(
94
- source: @df_path,
95
- transformers: @transformers
96
- )
69
+ # Load cached CSV - transformers already applied when data was first fetched
70
+ # Don't reapply them as columns are already in correct format
71
+ @df = SQA::DataFrame.load(source: @df_path)
72
+
73
+ migrated = false
74
+
75
+ # Migration 1: Rename old column names to new convention
76
+ # Old files may have: open, high, low, close
77
+ # New files should have: open_price, high_price, low_price, close_price
78
+ if @df.columns.include?("open") && !@df.columns.include?("open_price")
79
+ old_to_new_mapping = {
80
+ "open" => "open_price",
81
+ "high" => "high_price",
82
+ "low" => "low_price",
83
+ "close" => "close_price"
84
+ }
85
+ @df.rename_columns!(old_to_new_mapping)
86
+ migrated = true
87
+ end
88
+
89
+ # Migration 2: Add adj_close_price column if missing (for old cached files)
90
+ # This ensures compatibility when appending new data that includes this column
91
+ unless @df.columns.include?("adj_close_price")
92
+ @df.data = @df.data.with_column(
93
+ @df.data["close_price"].alias("adj_close_price")
94
+ )
95
+ migrated = true
96
+ end
97
+
98
+ # Save migrated DataFrame to avoid repeating migration
99
+ @df.to_csv(@df_path) if migrated
97
100
  else
98
- @df = klass.recent(@ticker, full: true)
99
- @df.to_csv(@df_path)
100
- return
101
+ # Fetch fresh data from source (applies transformers and mapping)
102
+ begin
103
+ @df = @klass.recent(@ticker, full: true)
104
+ @df.to_csv(@df_path)
105
+ return
106
+ rescue => e
107
+ # If we can't fetch data, raise a more helpful error
108
+ raise "Unable to fetch data for #{@ticker}. Please ensure API key is set or provide cached CSV file at #{@df_path}. Error: #{e.message}"
109
+ end
101
110
  end
102
111
 
103
- from_date = Date.parse(@df.timestamp.last) + 1
104
- df2 = klass.recent(@ticker, from_date: from_date)
112
+ update_dataframe_with_recent_data
113
+ end
105
114
 
106
- return if df2.nil? # CSV file is up to date.
115
+ def update_dataframe_with_recent_data
116
+ return unless should_update?
107
117
 
108
- df_nrows = @df.nrows
109
- @df.append!(df2)
118
+ begin
119
+ # CSV is sorted ascending (oldest first, TA-Lib compatible), so .last gets the most recent date
120
+ from_date = Date.parse(@df["timestamp"].to_a.last)
121
+ df2 = @klass.recent(@ticker, from_date: from_date)
110
122
 
111
- if @df.nrows > df_nrows
112
- @df.to_csv(@df_path)
123
+ if df2 && (df2.size > 0)
124
+ # Use concat_and_deduplicate! to prevent duplicate timestamps and maintain ascending sort
125
+ @df.concat_and_deduplicate!(df2)
126
+ @df.to_csv(@df_path)
127
+ end
128
+ rescue => e
129
+ # Log warning but don't fail - we have cached data
130
+ # Common causes: rate limits, network issues, API errors
131
+ warn "Warning: Could not update #{@ticker} from API (#{e.class}: #{e.message}). Using cached data."
113
132
  end
114
133
  end
115
134
 
135
+ def should_update?
136
+ # Don't update if we're in lazy update mode
137
+ return false if SQA.config.lazy_update
138
+
139
+ # Don't update if we don't have an API key (only relevant for Alpha Vantage)
140
+ if @source == :alpha_vantage
141
+ begin
142
+ SQA.av_api_key
143
+ rescue
144
+ return false
145
+ end
146
+ end
147
+
148
+ # Don't update if CSV data is already current (last timestamp is today or later)
149
+ # This prevents unnecessary API calls when we already have today's data
150
+ if @df && @df.size > 0
151
+ begin
152
+ last_timestamp = Date.parse(@df["timestamp"].to_a.last)
153
+ return false if last_timestamp >= Date.today
154
+ rescue => e
155
+ # If we can't parse the date, assume we need to update
156
+ warn "Warning: Could not parse last timestamp for #{@ticker} (#{e.message}). Will attempt update." if $VERBOSE
157
+ end
158
+ end
159
+
160
+ true
161
+ end
116
162
 
117
163
  def to_s
118
- "#{ticker} with #{@df.size} data points from #{@df.timestamp.first} to #{@df.timestamp.last}"
164
+ "#{ticker} with #{@df.size} data points from #{@df["timestamp"].to_a.first} to #{@df["timestamp"].to_a.last}"
119
165
  end
166
+ # Note: CSV data is stored in ascending chronological order (oldest to newest)
167
+ # This ensures compatibility with TA-Lib indicators which expect arrays in this order
120
168
  alias_method :inspect, :to_s
121
169
 
122
-
123
170
  def merge_overview
124
171
  temp = JSON.parse(
125
172
  CONNECTION.get("/query?function=OVERVIEW&symbol=#{ticker.upcase}&apikey=#{SQA.av.key}")
126
- .to_hash[:body]
173
+ .to_hash[:body]
127
174
  )
128
175
 
129
176
  if temp.has_key? "Information"
130
177
  ApiError.raise(temp["Information"])
131
178
  end
132
179
 
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
180
  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
- ]
181
+ 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
182
 
145
183
  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
184
+ new_k = k.underscore
185
+ temp2[new_k] = string_values.include?(new_k) ? temp[k] : temp[k].to_f
148
186
  end
149
187
 
150
188
  @data.overview = temp2
151
189
  end
152
190
 
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
191
  #############################################
179
192
  ## Class Methods
180
193
 
181
194
  class << self
182
- @@top = nil
183
-
184
- # Top Gainers, Losers and Most Active for most
185
- # recent closed trading day.
186
- #
187
195
  def top
188
196
  return @@top unless @@top.nil?
189
197
 
190
- a_hash = JSON.parse(
191
- CONNECTION.get(
192
- "/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}"
193
- ).to_hash[:body]
194
- )
198
+ a_hash = JSON.parse(CONNECTION.get("/query?function=TOP_GAINERS_LOSERS&apikey=#{SQA.av.key}").to_hash[:body])
195
199
 
196
200
  mash = Hashie::Mash.new(a_hash)
197
201
 
@@ -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