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