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,947 @@
1
+ # frozen_string_literal: true
2
+
3
+ =begin
4
+
5
+ Strategy Generator - Reverse Engineering Profitable Trades
6
+
7
+ This module analyzes historical price data to identify inflection points (turning points)
8
+ that precede significant price movements. It discovers which indicator patterns were
9
+ present at those inflection points.
10
+
11
+ FPOP (Future Period of Performance): The number of days to look ahead from an
12
+ inflection point to measure if the price change exceeds the threshold.
13
+
14
+ Process:
15
+ 1. Detect inflection points (local minima for buys, local maxima for sells)
16
+ 2. Check if price change during fpop period exceeds threshold percentage
17
+ 3. Calculate all indicators at those profitable inflection points
18
+ 4. Identify which indicators were "active" (in buy/sell zones)
19
+ 5. Find common patterns across profitable trades
20
+ 6. Generate trading rules from discovered patterns
21
+ 7. Optionally create KBS rules or strategy classes
22
+
23
+ Example:
24
+ generator = SQA::StrategyGenerator.new(
25
+ stock: stock,
26
+ min_gain_percent: 10.0,
27
+ fpop: 10 # Future Period of Performance (days)
28
+ )
29
+
30
+ patterns = generator.discover_patterns
31
+ strategy = generator.generate_strategy
32
+
33
+ =end
34
+
35
+ module SQA
36
+ class StrategyGenerator
37
+ # Represents a profitable trade opportunity discovered in historical data
38
+ class ProfitablePoint
39
+ attr_accessor :entry_index, :entry_price, :exit_index, :exit_price,
40
+ :gain_percent, :holding_days, :indicators,
41
+ :fpl_min_delta, :fpl_max_delta, :fpl_risk, :fpl_direction, :fpl_magnitude
42
+
43
+ def initialize(entry_index:, entry_price:, exit_index:, exit_price:, fpl_data: nil)
44
+ @entry_index = entry_index
45
+ @entry_price = entry_price
46
+ @exit_index = exit_index
47
+ @exit_price = exit_price
48
+ @gain_percent = ((exit_price - entry_price) / entry_price * 100.0)
49
+ @holding_days = exit_index - entry_index
50
+ @indicators = {}
51
+
52
+ # FPL quality metrics
53
+ if fpl_data
54
+ @fpl_min_delta = fpl_data[:min_delta]
55
+ @fpl_max_delta = fpl_data[:max_delta]
56
+ @fpl_risk = fpl_data[:risk]
57
+ @fpl_direction = fpl_data[:direction]
58
+ @fpl_magnitude = fpl_data[:magnitude]
59
+ end
60
+ end
61
+
62
+ def to_s
63
+ fpl_info = fpl_direction ? " dir=#{fpl_direction} risk=#{fpl_risk.round(2)}%" : ""
64
+ "ProfitablePoint(gain=#{gain_percent.round(2)}%, days=#{holding_days}, entry=#{entry_index}#{fpl_info})"
65
+ end
66
+ end
67
+
68
+ # Represents a discovered indicator pattern
69
+ class Pattern
70
+ attr_accessor :conditions, :frequency, :avg_gain, :avg_holding_days,
71
+ :success_rate, :occurrences,
72
+ :context
73
+
74
+ def initialize(conditions: {})
75
+ @conditions = conditions
76
+ @frequency = 0
77
+ @avg_gain = 0.0
78
+ @avg_holding_days = 0.0
79
+ @success_rate = 0.0
80
+ @occurrences = []
81
+ @context = PatternContext.new
82
+ end
83
+
84
+ def to_s
85
+ ctx_info = @context.valid? ? " [#{@context.summary}]" : ""
86
+ "Pattern(conditions=#{conditions.size}, freq=#{frequency}, gain=#{avg_gain.round(2)}%, success=#{success_rate.round(2)}%#{ctx_info})"
87
+ end
88
+ end
89
+
90
+ # Pattern Context - metadata about when/where pattern is valid
91
+ class PatternContext
92
+ attr_accessor :market_regime, :valid_months, :valid_quarters,
93
+ :discovered_period, :validation_period,
94
+ :stability_score, :sector, :volatility_regime
95
+
96
+ def initialize
97
+ @market_regime = nil # :bull, :bear, :sideways
98
+ @valid_months = [] # [10, 11, 12, 1] for Q4/Q1
99
+ @valid_quarters = [] # [1, 4] for Q1/Q4
100
+ @discovered_period = nil # "2020-01-01 to 2022-12-31"
101
+ @validation_period = nil # "2023-01-01 to 2024-11-08"
102
+ @stability_score = nil # 0.0-1.0, how consistent over time
103
+ @sector = nil # :technology, :finance, etc.
104
+ @volatility_regime = nil # :low, :medium, :high
105
+ end
106
+
107
+ def valid?
108
+ @market_regime || @valid_months.any? || @sector
109
+ end
110
+
111
+ def summary
112
+ parts = []
113
+ parts << @market_regime.to_s if @market_regime
114
+ parts << "months:#{@valid_months.join(',')}" if @valid_months.any?
115
+ parts << "Q#{@valid_quarters.join(',')}" if @valid_quarters.any?
116
+ parts << @sector.to_s if @sector
117
+ parts.join(' ')
118
+ end
119
+
120
+ # Check if pattern is valid for given date and conditions
121
+ def valid_for?(date: nil, regime: nil, sector: nil)
122
+ # Check market regime
123
+ return false if @market_regime && regime && @market_regime != regime
124
+
125
+ # Check sector
126
+ return false if @sector && sector && @sector != sector
127
+
128
+ # Check calendar constraints
129
+ if date
130
+ return false if @valid_months.any? && !@valid_months.include?(date.month)
131
+
132
+ quarter = ((date.month - 1) / 3) + 1
133
+ return false if @valid_quarters.any? && !@valid_quarters.include?(quarter)
134
+ end
135
+
136
+ true
137
+ end
138
+ end
139
+
140
+ attr_reader :stock, :profitable_points, :patterns, :min_gain_percent,
141
+ :fpop, :min_loss_percent, :indicators_config, :inflection_window,
142
+ :max_fpl_risk, :required_fpl_directions
143
+
144
+ def initialize(stock:, min_gain_percent: 10.0, min_loss_percent: nil, fpop: 10, inflection_window: 3, max_fpl_risk: nil, required_fpl_directions: nil)
145
+ @stock = stock
146
+ @min_gain_percent = min_gain_percent
147
+ @min_loss_percent = min_loss_percent || -min_gain_percent # Symmetric loss threshold
148
+ @fpop = fpop # Future Period of Performance
149
+ @inflection_window = inflection_window # Window for detecting local min/max
150
+ @max_fpl_risk = max_fpl_risk # Optional: Filter by max acceptable risk (volatility)
151
+ @required_fpl_directions = required_fpl_directions # Optional: [:UP, :DOWN, :UNCERTAIN, :FLAT]
152
+ @profitable_points = []
153
+ @patterns = []
154
+
155
+ # Configure which indicators to analyze
156
+ @indicators_config = {
157
+ rsi: { period: 14, oversold: 30, overbought: 70 },
158
+ macd: { fast: 12, slow: 26, signal: 9 },
159
+ stoch: { k_period: 14, d_period: 3, oversold: 20, overbought: 80 },
160
+ sma_cross: { short: 20, long: 50 },
161
+ ema: { period: 20 },
162
+ bbands: { period: 20, nbdev: 2.0 },
163
+ volume: { period: 20, threshold: 1.5 }
164
+ }
165
+ end
166
+
167
+ # Main entry point: Discover patterns in historical data
168
+ def discover_patterns(min_pattern_frequency: 2)
169
+ puts "=" * 70
170
+ puts "Strategy Generator: Discovering Profitable Patterns"
171
+ puts "=" * 70
172
+ puts "Target gain: ≥#{min_gain_percent}%"
173
+ puts "Target loss: ≤#{min_loss_percent}%"
174
+ puts "FPOP (Future Period of Performance): #{fpop} days"
175
+ puts "Inflection window: #{inflection_window} days"
176
+ puts
177
+
178
+ # Step 1: Find profitable inflection points
179
+ find_profitable_points
180
+
181
+ return [] if @profitable_points.empty?
182
+
183
+ # Step 2: Calculate indicators at each profitable point
184
+ analyze_indicator_states
185
+
186
+ # Step 3: Mine patterns from indicator states
187
+ mine_patterns(min_frequency: min_pattern_frequency)
188
+
189
+ # Step 4: Calculate pattern statistics
190
+ calculate_pattern_statistics
191
+
192
+ @patterns
193
+ end
194
+
195
+ # Generate a trading strategy from discovered patterns
196
+ def generate_strategy(pattern_index: 0, strategy_type: :proc)
197
+ return nil if @patterns.empty?
198
+
199
+ pattern = @patterns[pattern_index]
200
+
201
+ case strategy_type
202
+ when :proc
203
+ generate_proc_strategy(pattern)
204
+ when :class
205
+ generate_class_strategy(pattern)
206
+ when :kbs
207
+ generate_kbs_strategy(pattern)
208
+ else
209
+ raise "Unknown strategy type: #{strategy_type}"
210
+ end
211
+ end
212
+
213
+ # Generate multiple strategies from top N patterns
214
+ def generate_strategies(top_n: 5, strategy_type: :class)
215
+ @patterns.take(top_n).map.with_index do |pattern, i|
216
+ generate_strategy(pattern_index: i, strategy_type: strategy_type)
217
+ end
218
+ end
219
+
220
+ # Print discovered patterns
221
+ def print_patterns(max_patterns: 10)
222
+ puts "\n" + "=" * 70
223
+ puts "Discovered Patterns (Top #{[max_patterns, @patterns.size].min})"
224
+ puts "=" * 70
225
+
226
+ @patterns.take(max_patterns).each_with_index do |pattern, i|
227
+ puts "\nPattern ##{i + 1}:"
228
+ puts " Frequency: #{pattern.frequency} occurrences"
229
+ puts " Average Gain: #{pattern.avg_gain.round(2)}%"
230
+ puts " Average Holding: #{pattern.avg_holding_days.round(1)} days"
231
+ puts " Success Rate: #{pattern.success_rate.round(2)}%"
232
+ puts " Conditions:"
233
+ pattern.conditions.each do |indicator, state|
234
+ puts " - #{indicator}: #{state}"
235
+ end
236
+ end
237
+ puts
238
+ end
239
+
240
+ # Export patterns to CSV
241
+ def export_patterns(filename)
242
+ require 'csv'
243
+
244
+ CSV.open(filename, 'w') do |csv|
245
+ csv << ['Pattern', 'Frequency', 'Avg Gain %', 'Avg Holding Days', 'Success Rate %', 'Conditions']
246
+
247
+ @patterns.each_with_index do |pattern, i|
248
+ conditions_str = pattern.conditions.map { |k, v| "#{k}=#{v}" }.join('; ')
249
+ csv << [
250
+ i + 1,
251
+ pattern.frequency,
252
+ pattern.avg_gain.round(2),
253
+ pattern.avg_holding_days.round(1),
254
+ pattern.success_rate.round(2),
255
+ conditions_str
256
+ ]
257
+ end
258
+ end
259
+
260
+ puts "Patterns exported to #{filename}"
261
+ end
262
+
263
+ # Walk-forward validation - discover patterns with time-series cross-validation
264
+ #
265
+ # Splits data into train/test windows and rolls forward through history
266
+ # to prevent overfitting. Only keeps patterns that work out-of-sample.
267
+ #
268
+ # @param train_size [Integer] Training window size in days
269
+ # @param test_size [Integer] Testing window size in days
270
+ # @param step_size [Integer] How many days to step forward each iteration
271
+ # @return [Hash] Validation results with patterns and performance
272
+ #
273
+ def walk_forward_validate(train_size: 250, test_size: 60, step_size: 30)
274
+ puts "\n" + "=" * 70
275
+ puts "Walk-Forward Validation"
276
+ puts "=" * 70
277
+ puts "Training window: #{train_size} days"
278
+ puts "Testing window: #{test_size} days"
279
+ puts "Step size: #{step_size} days"
280
+ puts
281
+
282
+ prices = @stock.df["adj_close_price"].to_a
283
+
284
+
285
+ date_column = @stock.df.data.columns.include?("date") ? "date" : "timestamp"
286
+ dates = @stock.df[date_column].to_a.map { |d| Date.parse(d.to_s) }
287
+
288
+ validated_patterns = []
289
+ validation_results = []
290
+
291
+ start_idx = 0
292
+ iteration = 0
293
+
294
+ while start_idx + train_size + test_size < prices.size
295
+ iteration += 1
296
+ train_start = start_idx
297
+ train_end = start_idx + train_size
298
+ test_start = train_end
299
+ test_end = test_start + test_size
300
+
301
+ puts "\nIteration #{iteration}:"
302
+ puts " Train: #{dates[train_start]} to #{dates[train_end - 1]}"
303
+ puts " Test: #{dates[test_start]} to #{dates[test_end - 1]}"
304
+
305
+ # Create temporary stock with training data
306
+ train_data = create_stock_subset(train_start, train_end)
307
+
308
+ # Discover patterns on training data
309
+ temp_generator = SQA::StrategyGenerator.new(
310
+ stock: train_data,
311
+ min_gain_percent: @min_gain_percent,
312
+ fpop: @fpop,
313
+ inflection_window: @inflection_window,
314
+ max_fpl_risk: @max_fpl_risk,
315
+ required_fpl_directions: @required_fpl_directions
316
+ )
317
+
318
+ train_patterns = temp_generator.discover_patterns(min_pattern_frequency: 2)
319
+
320
+ # Test each pattern on out-of-sample data
321
+ test_data = create_stock_subset(test_start, test_end)
322
+
323
+ train_patterns.each do |pattern|
324
+ # Generate strategy from pattern
325
+ strategy = temp_generator.generate_strategy(
326
+ pattern_index: train_patterns.index(pattern),
327
+ strategy_type: :proc
328
+ )
329
+
330
+ # Backtest on test period
331
+ begin
332
+ backtest = SQA::Backtest.new(stock: test_data, strategy: strategy)
333
+ results = backtest.run
334
+
335
+ # Store validation result
336
+ validation_results << {
337
+ iteration: iteration,
338
+ pattern: pattern,
339
+ train_period: "#{dates[train_start]} to #{dates[train_end - 1]}",
340
+ test_period: "#{dates[test_start]} to #{dates[test_end - 1]}",
341
+ test_return: results.total_return,
342
+ test_sharpe: results.sharpe_ratio,
343
+ test_max_drawdown: results.max_drawdown
344
+ }
345
+
346
+ # Keep pattern if it performed well out-of-sample
347
+ if results.total_return > 0 && results.sharpe_ratio > 0.5
348
+ validated_patterns << pattern
349
+ end
350
+ rescue => e
351
+ puts " Warning: Pattern validation failed: #{e.message}"
352
+ end
353
+ end
354
+
355
+ start_idx += step_size
356
+ end
357
+
358
+ puts "\n" + "=" * 70
359
+ puts "Validation Complete"
360
+ puts " Total iterations: #{iteration}"
361
+ puts " Total patterns tested: #{validation_results.size}"
362
+ puts " Patterns validated: #{validated_patterns.size}"
363
+ puts "=" * 70
364
+
365
+ {
366
+ validated_patterns: validated_patterns,
367
+ validation_results: validation_results,
368
+ total_iterations: iteration
369
+ }
370
+ end
371
+
372
+ # Discover patterns with context (regime, seasonal, sector)
373
+ #
374
+ # @param analyze_regime [Boolean] Detect and filter by market regime
375
+ # @param analyze_seasonal [Boolean] Detect seasonal patterns
376
+ # @param sector [Symbol] Sector classification
377
+ # @return [Array<Pattern>] Patterns with context metadata
378
+ #
379
+ def discover_context_aware_patterns(analyze_regime: true, analyze_seasonal: true, sector: nil)
380
+ puts "\n" + "=" * 70
381
+ puts "Context-Aware Pattern Discovery"
382
+ puts "=" * 70
383
+
384
+ # Step 1: Detect market regime
385
+ if analyze_regime
386
+ regime_data = SQA::MarketRegime.detect(@stock)
387
+ puts "Current regime: #{regime_data[:type]} (#{regime_data[:strength]} strength)"
388
+
389
+ # Split data by regime
390
+ regime_splits = SQA::MarketRegime.split_by_regime(@stock)
391
+
392
+ puts "\nRegime periods:"
393
+ regime_splits.each do |regime, periods|
394
+ total_days = periods.sum { |p| p[:duration] }
395
+ puts " #{regime}: #{total_days} days across #{periods.size} periods"
396
+ end
397
+ end
398
+
399
+ # Step 2: Analyze seasonality
400
+ if analyze_seasonal
401
+ seasonal_data = SQA::SeasonalAnalyzer.analyze(@stock)
402
+ puts "\nSeasonal analysis:"
403
+ puts " Best months: #{seasonal_data[:best_months].join(', ')}"
404
+ puts " Worst months: #{seasonal_data[:worst_months].join(', ')}"
405
+ puts " Best quarters: Q#{seasonal_data[:best_quarters].join(', Q')}"
406
+ puts " Has seasonal pattern: #{seasonal_data[:has_seasonal_pattern]}"
407
+ end
408
+
409
+ # Step 3: Discover patterns normally
410
+ patterns = discover_patterns
411
+
412
+ # Step 4: Add context to each pattern
413
+ patterns.each do |pattern|
414
+ if analyze_regime
415
+ pattern.context.market_regime = regime_data[:type]
416
+ pattern.context.volatility_regime = regime_data[:volatility]
417
+ end
418
+
419
+ if analyze_seasonal && seasonal_data[:has_seasonal_pattern]
420
+ pattern.context.valid_months = seasonal_data[:best_months]
421
+ pattern.context.valid_quarters = seasonal_data[:best_quarters]
422
+ end
423
+
424
+ if sector
425
+ pattern.context.sector = sector
426
+ end
427
+
428
+ # Add discovery period
429
+
430
+ date_column = @stock.df.data.columns.include?("date") ? "date" : "timestamp"
431
+ dates = @stock.df[date_column].to_a
432
+
433
+ pattern.context.discovered_period = "#{dates.first} to #{dates.last}"
434
+ end
435
+
436
+ puts "\n" + "=" * 70
437
+ puts "Context-Aware Discovery Complete"
438
+ puts " Patterns found: #{patterns.size}"
439
+ puts " Patterns with context: #{patterns.count { |p| p.context.valid? }}"
440
+ puts "=" * 70
441
+
442
+ patterns
443
+ end
444
+
445
+ private
446
+
447
+ # Step 1: Find all profitable inflection points
448
+ def find_profitable_points
449
+ puts "Step 1: Detecting inflection points and analyzing FPOP..."
450
+
451
+ prices = @stock.df["adj_close_price"].to_a
452
+
453
+ # Step 1a: Calculate FPL analysis for all points
454
+ fpl_analysis = SQA::FPOP.fpl_analysis(prices, fpop: @fpop)
455
+
456
+ # Step 1b: Detect inflection points (local minima and maxima)
457
+ inflection_points = detect_inflection_points(prices)
458
+ puts " Found #{inflection_points.size} inflection points"
459
+
460
+ # Step 1c: Check which inflection points lead to profitable moves
461
+ profitable_count = 0
462
+ filtered_by_risk = 0
463
+ filtered_by_direction = 0
464
+
465
+ inflection_points.each do |inflection_idx|
466
+ # Skip if not enough future data
467
+ next if inflection_idx + @fpop >= prices.size
468
+ next if inflection_idx >= fpl_analysis.size
469
+
470
+ entry_price = prices[inflection_idx]
471
+ fpl_data = fpl_analysis[inflection_idx]
472
+
473
+ # Optional: Filter by FPL risk (volatility)
474
+ if @max_fpl_risk && fpl_data[:risk] > @max_fpl_risk
475
+ filtered_by_risk += 1
476
+ next
477
+ end
478
+
479
+ # Optional: Filter by FPL direction
480
+ if @required_fpl_directions && !@required_fpl_directions.include?(fpl_data[:direction])
481
+ filtered_by_direction += 1
482
+ next
483
+ end
484
+
485
+ # Calculate price change over fpop period
486
+ future_prices = prices[(inflection_idx + 1)..(inflection_idx + @fpop)]
487
+ max_future_price = future_prices.max
488
+ min_future_price = future_prices.min
489
+
490
+ # Calculate gain/loss percentages
491
+ max_gain_percent = ((max_future_price - entry_price) / entry_price * 100.0)
492
+ max_loss_percent = ((min_future_price - entry_price) / entry_price * 100.0)
493
+
494
+ # Check if gain exceeds threshold (buy opportunity)
495
+ if max_gain_percent >= @min_gain_percent
496
+ exit_idx = inflection_idx + 1 + future_prices.index(max_future_price)
497
+
498
+ @profitable_points << ProfitablePoint.new(
499
+ entry_index: inflection_idx,
500
+ entry_price: entry_price,
501
+ exit_index: exit_idx,
502
+ exit_price: max_future_price,
503
+ fpl_data: fpl_data
504
+ )
505
+ profitable_count += 1
506
+ # Check if loss exceeds threshold (sell opportunity)
507
+ elsif max_loss_percent <= @min_loss_percent
508
+ exit_idx = inflection_idx + 1 + future_prices.index(min_future_price)
509
+
510
+ @profitable_points << ProfitablePoint.new(
511
+ entry_index: inflection_idx,
512
+ entry_price: entry_price,
513
+ exit_index: exit_idx,
514
+ exit_price: min_future_price,
515
+ fpl_data: fpl_data
516
+ )
517
+ profitable_count += 1
518
+ end
519
+ end
520
+
521
+ puts " Inflection points analyzed: #{inflection_points.size}"
522
+ puts " Filtered by risk: #{filtered_by_risk}" if @max_fpl_risk
523
+ puts " Filtered by direction: #{filtered_by_direction}" if @required_fpl_directions
524
+ puts " Profitable opportunities found: #{@profitable_points.size}"
525
+ if inflection_points.size > 0
526
+ puts " Success rate: #{(@profitable_points.size.to_f / inflection_points.size * 100).round(2)}%"
527
+ end
528
+
529
+ # Print FPL quality stats
530
+ if @profitable_points.any? && @profitable_points.first.fpl_direction
531
+ avg_risk = @profitable_points.map(&:fpl_risk).compact.sum / @profitable_points.size
532
+ avg_magnitude = @profitable_points.map(&:fpl_magnitude).compact.sum / @profitable_points.size
533
+ directions = @profitable_points.map(&:fpl_direction).compact.tally
534
+ puts " Average FPL risk: #{avg_risk.round(2)}%"
535
+ puts " Average FPL magnitude: #{avg_magnitude.round(2)}%"
536
+ puts " Direction distribution: #{directions}"
537
+ end
538
+ puts
539
+ end
540
+
541
+ # Detect inflection points (local minima and maxima)
542
+ def detect_inflection_points(prices)
543
+ inflection_points = []
544
+ window = @inflection_window
545
+
546
+ # Scan for local minima and maxima
547
+ (window...(prices.size - window)).each do |idx|
548
+ current_price = prices[idx]
549
+
550
+ # Get surrounding window
551
+ left_window = prices[(idx - window)...idx]
552
+ right_window = prices[(idx + 1)..(idx + window)]
553
+
554
+ # Check if local minimum (potential buy point)
555
+ if left_window.all? { |p| current_price <= p } && right_window.all? { |p| current_price <= p }
556
+ inflection_points << idx
557
+ # Check if local maximum (potential sell point)
558
+ elsif left_window.all? { |p| current_price >= p } && right_window.all? { |p| current_price >= p }
559
+ inflection_points << idx
560
+ end
561
+ end
562
+
563
+ inflection_points
564
+ end
565
+
566
+ # Step 2: Calculate indicator states at each profitable point
567
+ def analyze_indicator_states
568
+ puts "Step 2: Analyzing indicator states at profitable points..."
569
+
570
+ prices = @stock.df["adj_close_price"].to_a
571
+ volumes = @stock.df["volume"].to_a
572
+ highs = @stock.df["high_price"].to_a
573
+ lows = @stock.df["low_price"].to_a
574
+
575
+ # Pre-calculate all indicators for efficiency
576
+ indicator_cache = calculate_all_indicators(prices, volumes, highs, lows)
577
+
578
+ @profitable_points.each do |point|
579
+ idx = point.entry_index
580
+ point.indicators = extract_indicator_states(idx, indicator_cache, prices, volumes)
581
+ end
582
+
583
+ puts " Analyzed #{@profitable_points.size} profitable points"
584
+ puts
585
+ end
586
+
587
+ # Calculate all indicators once for efficiency
588
+ def calculate_all_indicators(prices, volumes, highs, lows)
589
+ cache = {}
590
+
591
+ # RSI
592
+ rsi_config = @indicators_config[:rsi]
593
+ cache[:rsi] = SQAI.rsi(prices, period: rsi_config[:period])
594
+
595
+ # MACD
596
+ macd_config = @indicators_config[:macd]
597
+ macd_line, signal_line, histogram = SQAI.macd(
598
+ prices,
599
+ fast_period: macd_config[:fast],
600
+ slow_period: macd_config[:slow],
601
+ signal_period: macd_config[:signal]
602
+ )
603
+ cache[:macd_line] = macd_line
604
+ cache[:macd_signal] = signal_line
605
+ cache[:macd_histogram] = histogram
606
+
607
+ # Stochastic
608
+ stoch_config = @indicators_config[:stoch]
609
+ stoch_k, stoch_d = SQAI.stoch(
610
+ highs, lows, prices,
611
+ fastk_period: stoch_config[:k_period],
612
+ slowk_period: stoch_config[:d_period],
613
+ slowd_period: stoch_config[:d_period]
614
+ )
615
+ cache[:stoch_k] = stoch_k
616
+ cache[:stoch_d] = stoch_d
617
+
618
+ # SMAs
619
+ sma_config = @indicators_config[:sma_cross]
620
+ cache[:sma_short] = SQAI.sma(prices, period: sma_config[:short])
621
+ cache[:sma_long] = SQAI.sma(prices, period: sma_config[:long])
622
+
623
+ # EMA
624
+ ema_config = @indicators_config[:ema]
625
+ cache[:ema] = SQAI.ema(prices, period: ema_config[:period])
626
+
627
+ # Bollinger Bands
628
+ bb_config = @indicators_config[:bbands]
629
+ upper, middle, lower = SQAI.bbands(
630
+ prices,
631
+ period: bb_config[:period],
632
+ nbdev_up: bb_config[:nbdev],
633
+ nbdev_down: bb_config[:nbdev]
634
+ )
635
+ cache[:bb_upper] = upper
636
+ cache[:bb_middle] = middle
637
+ cache[:bb_lower] = lower
638
+
639
+ cache
640
+ rescue => e
641
+ puts " Warning: Indicator calculation failed: #{e.message}"
642
+ {}
643
+ end
644
+
645
+ # Extract indicator states at a specific index
646
+ def extract_indicator_states(idx, cache, prices, volumes)
647
+ states = {}
648
+
649
+ # RSI state
650
+ if cache[:rsi] && idx < cache[:rsi].size
651
+ rsi_val = cache[:rsi][idx]
652
+ rsi_config = @indicators_config[:rsi]
653
+
654
+ states[:rsi] = if rsi_val < rsi_config[:oversold]
655
+ :oversold
656
+ elsif rsi_val > rsi_config[:overbought]
657
+ :overbought
658
+ else
659
+ :neutral
660
+ end
661
+ states[:rsi_value] = rsi_val
662
+ end
663
+
664
+ # MACD state
665
+ if cache[:macd_line] && cache[:macd_signal] && idx >= 1
666
+ macd_curr = cache[:macd_line][idx]
667
+ signal_curr = cache[:macd_signal][idx]
668
+ macd_prev = cache[:macd_line][idx - 1]
669
+ signal_prev = cache[:macd_signal][idx - 1]
670
+
671
+ states[:macd_crossover] = if macd_prev <= signal_prev && macd_curr > signal_curr
672
+ :bullish
673
+ elsif macd_prev >= signal_prev && macd_curr < signal_curr
674
+ :bearish
675
+ else
676
+ :none
677
+ end
678
+ states[:macd_position] = macd_curr > signal_curr ? :above : :below
679
+ end
680
+
681
+ # Stochastic state
682
+ if cache[:stoch_k] && idx < cache[:stoch_k].size
683
+ stoch_k_val = cache[:stoch_k][idx]
684
+ stoch_config = @indicators_config[:stoch]
685
+
686
+ states[:stoch] = if stoch_k_val < stoch_config[:oversold]
687
+ :oversold
688
+ elsif stoch_k_val > stoch_config[:overbought]
689
+ :overbought
690
+ else
691
+ :neutral
692
+ end
693
+ end
694
+
695
+ # SMA crossover state
696
+ if cache[:sma_short] && cache[:sma_long] && idx < cache[:sma_short].size
697
+ sma_short = cache[:sma_short][idx]
698
+ sma_long = cache[:sma_long][idx]
699
+
700
+ states[:sma_cross] = sma_short > sma_long ? :golden : :death
701
+ end
702
+
703
+ # Bollinger Bands position
704
+ if cache[:bb_upper] && cache[:bb_lower] && idx < prices.size
705
+ price = prices[idx]
706
+ upper = cache[:bb_upper][idx]
707
+ lower = cache[:bb_lower][idx]
708
+
709
+ states[:bb_position] = if price < lower
710
+ :below_lower
711
+ elsif price > upper
712
+ :above_upper
713
+ else
714
+ :inside
715
+ end
716
+ end
717
+
718
+ # Price vs EMA
719
+ if cache[:ema] && idx < cache[:ema].size && idx < prices.size
720
+ price = prices[idx]
721
+ ema = cache[:ema][idx]
722
+
723
+ states[:price_vs_ema] = price > ema ? :above : :below
724
+ end
725
+
726
+ # Volume state
727
+ if idx >= 20 && volumes.size > idx
728
+ current_volume = volumes[idx]
729
+ avg_volume = volumes[(idx - 19)..idx].sum / 20.0
730
+ vol_config = @indicators_config[:volume]
731
+
732
+ states[:volume] = if current_volume > avg_volume * vol_config[:threshold]
733
+ :high
734
+ elsif current_volume < avg_volume * 0.5
735
+ :low
736
+ else
737
+ :normal
738
+ end
739
+ end
740
+
741
+ states
742
+ end
743
+
744
+ # Step 3: Mine patterns from indicator states
745
+ def mine_patterns(min_frequency: 2)
746
+ puts "Step 3: Mining patterns from indicator states..."
747
+
748
+ pattern_map = Hash.new { |h, k| h[k] = Pattern.new(conditions: k) }
749
+
750
+ # Generate all possible pattern combinations
751
+ @profitable_points.each do |point|
752
+ # Single indicator patterns
753
+ point.indicators.each do |indicator, state|
754
+ key = { indicator => state }
755
+ pattern_map[key].frequency += 1
756
+ pattern_map[key].occurrences << point
757
+ end
758
+
759
+ # Two-indicator patterns
760
+ indicators = point.indicators.to_a
761
+ indicators.combination(2).each do |combo|
762
+ key = combo.to_h
763
+ pattern_map[key].frequency += 1
764
+ pattern_map[key].occurrences << point
765
+ end
766
+
767
+ # Three-indicator patterns (for strong signals)
768
+ indicators.combination(3).each do |combo|
769
+ key = combo.to_h
770
+ pattern_map[key].frequency += 1
771
+ pattern_map[key].occurrences << point
772
+ end
773
+ end
774
+
775
+ # Filter patterns by minimum frequency
776
+ @patterns = pattern_map.values.select { |p| p.frequency >= min_frequency }
777
+
778
+ # Sort by frequency (most common first)
779
+ @patterns.sort_by! { |p| [-p.frequency, -p.conditions.size] }
780
+
781
+ puts " Found #{@patterns.size} patterns (min frequency: #{min_frequency})"
782
+ puts
783
+ end
784
+
785
+ # Step 4: Calculate pattern statistics
786
+ def calculate_pattern_statistics
787
+ puts "Step 4: Calculating pattern statistics..."
788
+
789
+ @patterns.each do |pattern|
790
+ gains = pattern.occurrences.map(&:gain_percent)
791
+ holding_days = pattern.occurrences.map(&:holding_days)
792
+
793
+ pattern.avg_gain = gains.sum / gains.size.to_f
794
+ pattern.avg_holding_days = holding_days.sum / holding_days.size.to_f
795
+
796
+ # Calculate success rate by backtesting the pattern
797
+ pattern.success_rate = calculate_success_rate(pattern)
798
+ end
799
+
800
+ # Re-sort by success rate and gain
801
+ @patterns.sort_by! { |p| [-p.success_rate, -p.avg_gain, -p.frequency] }
802
+
803
+ puts " Calculated statistics for #{@patterns.size} patterns"
804
+ puts
805
+ end
806
+
807
+ # Calculate success rate for a pattern across all history
808
+ def calculate_success_rate(pattern)
809
+ # Simplified: use frequency as proxy for success rate
810
+ # In production, you'd backtest the pattern
811
+ (pattern.frequency.to_f / @profitable_points.size * 100.0)
812
+ end
813
+
814
+ # Generate a Proc-based strategy
815
+ def generate_proc_strategy(pattern)
816
+ conditions = pattern.conditions.dup
817
+
818
+ lambda do |vector|
819
+ match_count = 0
820
+ total_conditions = conditions.size
821
+
822
+ conditions.each do |indicator, expected_state|
823
+ actual_state = get_indicator_state(vector, indicator)
824
+ match_count += 1 if actual_state == expected_state
825
+ end
826
+
827
+ # Require all conditions to match
828
+ match_count == total_conditions ? :buy : :hold
829
+ end
830
+ end
831
+
832
+ # Generate a Class-based strategy
833
+ def generate_class_strategy(pattern)
834
+ conditions = pattern.conditions.dup
835
+ generator = self
836
+
837
+ Class.new do
838
+ define_singleton_method(:trade) do |vector|
839
+ match_count = 0
840
+ total_conditions = conditions.size
841
+
842
+ conditions.each do |indicator, expected_state|
843
+ actual_state = generator.send(:get_indicator_state, vector, indicator)
844
+ match_count += 1 if actual_state == expected_state
845
+ end
846
+
847
+ match_count == total_conditions ? :buy : :hold
848
+ end
849
+
850
+ define_singleton_method(:pattern) do
851
+ conditions
852
+ end
853
+ end
854
+ end
855
+
856
+ # Generate a KBS-based strategy
857
+ def generate_kbs_strategy(pattern)
858
+ require_relative 'strategy/kbs_strategy'
859
+
860
+ strategy = SQA::Strategy::KBS.new(load_defaults: false)
861
+
862
+ # Build rule from pattern
863
+ strategy.add_rule :discovered_pattern do
864
+ pattern.conditions.each do |indicator, state|
865
+ on indicator, { state: state }
866
+ end
867
+
868
+ perform do
869
+ assert(:signal, {
870
+ action: :buy,
871
+ confidence: :high,
872
+ reason: :discovered_pattern
873
+ })
874
+ end
875
+ end
876
+
877
+ strategy
878
+ end
879
+
880
+ # Helper: Create stock subset for walk-forward validation
881
+ def create_stock_subset(start_idx, end_idx)
882
+ # Extract subset of data
883
+ subset_df_data = {}
884
+
885
+ @stock.df.columns.each do |col|
886
+ subset_df_data[col] = @stock.df[col].to_a[start_idx...end_idx]
887
+ end
888
+
889
+ # Create new stock object with subset
890
+ temp_stock = SQA::Stock.allocate
891
+ temp_stock.instance_variable_set(:@ticker, @stock.ticker)
892
+ temp_stock.instance_variable_set(:@df, SQA::DataFrame.new(subset_df_data))
893
+
894
+ temp_stock
895
+ end
896
+
897
+ # Helper: Get current indicator state from vector
898
+ def get_indicator_state(vector, indicator)
899
+ case indicator
900
+ when :rsi
901
+ return :neutral unless vector.respond_to?(:rsi) && vector.rsi
902
+ rsi_val = Array(vector.rsi).last
903
+ rsi_config = @indicators_config[:rsi]
904
+ if rsi_val < rsi_config[:oversold]
905
+ :oversold
906
+ elsif rsi_val > rsi_config[:overbought]
907
+ :overbought
908
+ else
909
+ :neutral
910
+ end
911
+
912
+ when :macd_crossover
913
+ return :none unless vector.respond_to?(:macd) && vector.macd
914
+ macd_line, signal_line = vector.macd
915
+ return :none if macd_line.size < 2 || signal_line.size < 2
916
+
917
+ macd_curr = macd_line.last
918
+ signal_curr = signal_line.last
919
+ macd_prev = macd_line[-2]
920
+ signal_prev = signal_line[-2]
921
+
922
+ if macd_prev <= signal_prev && macd_curr > signal_curr
923
+ :bullish
924
+ elsif macd_prev >= signal_prev && macd_curr < signal_curr
925
+ :bearish
926
+ else
927
+ :none
928
+ end
929
+
930
+ when :stoch
931
+ return :neutral unless vector.respond_to?(:stoch_k) && vector.stoch_k
932
+ stoch_k_val = Array(vector.stoch_k).last
933
+ stoch_config = @indicators_config[:stoch]
934
+ if stoch_k_val < stoch_config[:oversold]
935
+ :oversold
936
+ elsif stoch_k_val > stoch_config[:overbought]
937
+ :overbought
938
+ else
939
+ :neutral
940
+ end
941
+
942
+ else
943
+ :unknown
944
+ end
945
+ end
946
+ end
947
+ end