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,497 @@
1
+ # lib/sqa/pattern_matcher.rb
2
+ # frozen_string_literal: true
3
+
4
+ module SQA
5
+ ##
6
+ # PatternMatcher - Find similar historical patterns
7
+ #
8
+ # Provides methods for:
9
+ # - Pattern similarity search (nearest-neighbor)
10
+ # - Shape-based pattern matching
11
+ # - Predict future moves based on similar past patterns
12
+ # - Pattern clustering
13
+ #
14
+ # Uses techniques:
15
+ # - Euclidean distance
16
+ # - Dynamic Time Warping (DTW)
17
+ # - Pearson correlation
18
+ #
19
+ # @example Find similar patterns
20
+ # matcher = SQA::PatternMatcher.new(stock: stock)
21
+ # similar = matcher.find_similar(lookback: 10, num_matches: 5)
22
+ # # Returns 5 most similar historical 10-day patterns
23
+ #
24
+ class PatternMatcher
25
+ attr_reader :stock, :prices
26
+
27
+ ##
28
+ # Initialize pattern matcher
29
+ #
30
+ # @param stock [SQA::Stock] Stock object
31
+ #
32
+ def initialize(stock:)
33
+ @stock = stock
34
+ @prices = stock.df.data["adj_close_price"].to_a
35
+ end
36
+
37
+ ##
38
+ # Find similar historical patterns to current pattern
39
+ #
40
+ # @param lookback [Integer] Pattern length (days)
41
+ # @param num_matches [Integer] Number of similar patterns to find
42
+ # @param method [Symbol] Distance method (:euclidean, :dtw, :correlation)
43
+ # @param normalize [Boolean] Normalize patterns before comparison
44
+ # @return [Array<Hash>] Similar patterns with metadata
45
+ #
46
+ def find_similar(lookback: 10, num_matches: 5, method: :euclidean, normalize: true)
47
+ return [] if @prices.size < lookback * 2
48
+
49
+ # Current pattern (most recent)
50
+ current_pattern = @prices[-lookback..-1]
51
+ current_pattern = normalize_pattern(current_pattern) if normalize
52
+
53
+ similarities = []
54
+
55
+ # Search through historical data
56
+ (@prices.size - lookback - 20).times do |start_idx|
57
+ next if start_idx + lookback >= @prices.size - lookback # Don't compare to recent data
58
+
59
+ historical_pattern = @prices[start_idx, lookback]
60
+ historical_pattern = normalize_pattern(historical_pattern) if normalize
61
+
62
+ distance = case method
63
+ when :euclidean
64
+ euclidean_distance(current_pattern, historical_pattern)
65
+ when :dtw
66
+ dtw_distance(current_pattern, historical_pattern)
67
+ when :correlation
68
+ -correlation(current_pattern, historical_pattern) # Negative so lower is better
69
+ else
70
+ euclidean_distance(current_pattern, historical_pattern)
71
+ end
72
+
73
+ # What happened next?
74
+ future_start = start_idx + lookback
75
+ future_end = [future_start + lookback, @prices.size - 1].min
76
+ future_prices = @prices[future_start..future_end]
77
+
78
+ next if future_prices.empty?
79
+
80
+ future_return = (future_prices.last - @prices[start_idx + lookback - 1]) /
81
+ @prices[start_idx + lookback - 1]
82
+
83
+ similarities << {
84
+ start_index: start_idx,
85
+ end_index: start_idx + lookback - 1,
86
+ distance: distance,
87
+ pattern: historical_pattern,
88
+ future_return: future_return,
89
+ future_prices: future_prices,
90
+ pattern_start_price: @prices[start_idx],
91
+ pattern_end_price: @prices[start_idx + lookback - 1]
92
+ }
93
+ end
94
+
95
+ # Sort by distance and return top matches
96
+ similarities.sort_by { |s| s[:distance] }.first(num_matches)
97
+ end
98
+
99
+ ##
100
+ # Predict future price movement based on similar patterns
101
+ #
102
+ # @param lookback [Integer] Pattern length
103
+ # @param forecast_periods [Integer] Periods to forecast
104
+ # @param num_matches [Integer] Number of similar patterns to use
105
+ # @return [Hash] Forecast with confidence intervals
106
+ #
107
+ def forecast(lookback: 10, forecast_periods: 5, num_matches: 10)
108
+ similar = find_similar(lookback: lookback, num_matches: num_matches)
109
+
110
+ return nil if similar.empty?
111
+
112
+ # Collect future returns from similar patterns
113
+ future_returns = similar.map { |s| s[:future_return] }
114
+
115
+ # Statistical forecast
116
+ mean_return = future_returns.sum / future_returns.size.to_f
117
+ std_return = standard_deviation(future_returns)
118
+
119
+ current_price = @prices.last
120
+ forecast_price = current_price * (1 + mean_return)
121
+
122
+ {
123
+ forecast_price: forecast_price,
124
+ forecast_return: mean_return,
125
+ confidence_interval_95: [
126
+ current_price * (1 + mean_return - 1.96 * std_return),
127
+ current_price * (1 + mean_return + 1.96 * std_return)
128
+ ],
129
+ num_matches: similar.size,
130
+ similar_patterns: similar,
131
+ current_price: current_price
132
+ }
133
+ end
134
+
135
+ ##
136
+ # Detect chart patterns (head & shoulders, double top, etc.)
137
+ #
138
+ # @param pattern_type [Symbol] Pattern to detect
139
+ # @return [Array<Hash>] Detected patterns
140
+ #
141
+ def detect_chart_pattern(pattern_type)
142
+ case pattern_type
143
+ when :double_top
144
+ detect_double_top
145
+ when :double_bottom
146
+ detect_double_bottom
147
+ when :head_and_shoulders
148
+ detect_head_shoulders
149
+ when :triangle
150
+ detect_triangle
151
+ else
152
+ []
153
+ end
154
+ end
155
+
156
+ ##
157
+ # Cluster patterns by similarity
158
+ #
159
+ # @param pattern_length [Integer] Length of patterns
160
+ # @param num_clusters [Integer] Number of clusters
161
+ # @return [Array<Array<Hash>>] Clusters of similar patterns
162
+ #
163
+ def cluster_patterns(pattern_length: 10, num_clusters: 5)
164
+ return [] if @prices.size < pattern_length * num_clusters
165
+
166
+ # Extract all patterns
167
+ patterns = []
168
+ (@prices.size - pattern_length).times do |start_idx|
169
+ pattern = @prices[start_idx, pattern_length]
170
+ patterns << {
171
+ start_index: start_idx,
172
+ pattern: normalize_pattern(pattern),
173
+ raw_pattern: pattern
174
+ }
175
+ end
176
+
177
+ # Simple k-means clustering
178
+ clusters = Array.new(num_clusters) { [] }
179
+
180
+ # Initialize centroids randomly
181
+ centroids = patterns.sample(num_clusters).map { |p| p[:pattern] }
182
+
183
+ # Iterate until convergence
184
+ 10.times do
185
+ # Assign to nearest centroid
186
+ clusters = Array.new(num_clusters) { [] }
187
+
188
+ patterns.each do |pattern|
189
+ distances = centroids.map { |centroid| euclidean_distance(pattern[:pattern], centroid) }
190
+ nearest_cluster = distances.index(distances.min)
191
+ clusters[nearest_cluster] << pattern
192
+ end
193
+
194
+ # Update centroids
195
+ centroids = clusters.map do |cluster|
196
+ next centroids[0] if cluster.empty?
197
+
198
+ # Average pattern
199
+ pattern_length.times.map do |i|
200
+ cluster.map { |p| p[:pattern][i] }.sum / cluster.size.to_f
201
+ end
202
+ end
203
+ end
204
+
205
+ clusters.reject(&:empty?)
206
+ end
207
+
208
+ ##
209
+ # Calculate pattern strength/quality
210
+ #
211
+ # @param pattern [Array<Float>] Price pattern
212
+ # @return [Hash] Pattern quality metrics
213
+ #
214
+ def pattern_quality(pattern)
215
+ return nil if pattern.size < 3
216
+
217
+ # Trend strength
218
+ first = pattern.first
219
+ last = pattern.last
220
+ trend = (last - first) / first
221
+
222
+ # Volatility
223
+ returns = pattern.each_cons(2).map { |a, b| (b - a) / a }
224
+ volatility = standard_deviation(returns)
225
+
226
+ # Smoothness (how linear is the trend?)
227
+ x_values = (0...pattern.size).to_a
228
+ correlation = pearson_correlation(x_values, pattern)
229
+
230
+ {
231
+ trend: trend,
232
+ volatility: volatility,
233
+ smoothness: correlation.abs,
234
+ strength: correlation.abs * (1 - volatility) # Combined metric
235
+ }
236
+ end
237
+
238
+ private
239
+
240
+ ##
241
+ # Normalize pattern to 0-1 range
242
+ #
243
+ def normalize_pattern(pattern)
244
+ min = pattern.min
245
+ max = pattern.max
246
+ range = max - min
247
+
248
+ return pattern if range.zero?
249
+
250
+ pattern.map { |p| (p - min) / range }
251
+ end
252
+
253
+ ##
254
+ # Euclidean distance between two patterns
255
+ #
256
+ def euclidean_distance(pattern1, pattern2)
257
+ return Float::INFINITY if pattern1.size != pattern2.size
258
+
259
+ sum_squares = pattern1.zip(pattern2).sum { |a, b| (a - b)**2 }
260
+ Math.sqrt(sum_squares)
261
+ end
262
+
263
+ ##
264
+ # Dynamic Time Warping distance
265
+ #
266
+ # Allows patterns to be stretched in time for better matching.
267
+ #
268
+ def dtw_distance(pattern1, pattern2)
269
+ n = pattern1.size
270
+ m = pattern2.size
271
+
272
+ # Initialize DTW matrix
273
+ dtw = Array.new(n + 1) { Array.new(m + 1, Float::INFINITY) }
274
+ dtw[0][0] = 0
275
+
276
+ # Fill matrix
277
+ (1..n).each do |i|
278
+ (1..m).each do |j|
279
+ cost = (pattern1[i - 1] - pattern2[j - 1]).abs
280
+ dtw[i][j] = cost + [dtw[i - 1][j], dtw[i][j - 1], dtw[i - 1][j - 1]].min
281
+ end
282
+ end
283
+
284
+ dtw[n][m]
285
+ end
286
+
287
+ ##
288
+ # Correlation between two patterns
289
+ #
290
+ def correlation(pattern1, pattern2)
291
+ pearson_correlation(pattern1, pattern2)
292
+ end
293
+
294
+ ##
295
+ # Pearson correlation coefficient
296
+ #
297
+ def pearson_correlation(x, y)
298
+ return 0.0 if x.size != y.size || x.size < 2
299
+
300
+ n = x.size
301
+ sum_x = x.sum
302
+ sum_y = y.sum
303
+ sum_xy = x.zip(y).sum { |a, b| a * b }
304
+ sum_x2 = x.sum { |a| a**2 }
305
+ sum_y2 = y.sum { |a| a**2 }
306
+
307
+ numerator = (n * sum_xy) - (sum_x * sum_y)
308
+ denominator = Math.sqrt(((n * sum_x2) - sum_x**2) * ((n * sum_y2) - sum_y**2))
309
+
310
+ return 0.0 if denominator.zero?
311
+
312
+ numerator / denominator
313
+ end
314
+
315
+ ##
316
+ # Standard deviation
317
+ #
318
+ def standard_deviation(values)
319
+ return 0.0 if values.empty?
320
+
321
+ mean = values.sum / values.size.to_f
322
+ variance = values.map { |v| (v - mean)**2 }.sum / values.size.to_f
323
+ Math.sqrt(variance)
324
+ end
325
+
326
+ ##
327
+ # Detect double top pattern
328
+ #
329
+ def detect_double_top
330
+ peaks = find_peaks
331
+ patterns = []
332
+
333
+ peaks.each_cons(2) do |peak1, peak2|
334
+ next if (peak2[:index] - peak1[:index]) > 60 # Too far apart
335
+
336
+ # Similar heights?
337
+ price_diff = (peak1[:price] - peak2[:price]).abs / peak1[:price]
338
+ next if price_diff > 0.05 # More than 5% difference
339
+
340
+ # Valley between them?
341
+ valley_prices = @prices[(peak1[:index] + 1)...peak2[:index]]
342
+ valley_low = valley_prices.min
343
+
344
+ # Valley should be significantly lower
345
+ valley_drop = (peak1[:price] - valley_low) / peak1[:price]
346
+ next if valley_drop < 0.03 # Less than 3% drop
347
+
348
+ patterns << {
349
+ type: :double_top,
350
+ peak1_index: peak1[:index],
351
+ peak2_index: peak2[:index],
352
+ peak_price: (peak1[:price] + peak2[:price]) / 2.0,
353
+ valley_price: valley_low,
354
+ strength: valley_drop
355
+ }
356
+ end
357
+
358
+ patterns
359
+ end
360
+
361
+ ##
362
+ # Detect double bottom pattern
363
+ #
364
+ def detect_double_bottom
365
+ valleys = find_valleys
366
+ patterns = []
367
+
368
+ valleys.each_cons(2) do |valley1, valley2|
369
+ next if (valley2[:index] - valley1[:index]) > 60
370
+
371
+ price_diff = (valley1[:price] - valley2[:price]).abs / valley1[:price]
372
+ next if price_diff > 0.05
373
+
374
+ peak_prices = @prices[(valley1[:index] + 1)...valley2[:index]]
375
+ peak_high = peak_prices.max
376
+
377
+ peak_rise = (peak_high - valley1[:price]) / valley1[:price]
378
+ next if peak_rise < 0.03
379
+
380
+ patterns << {
381
+ type: :double_bottom,
382
+ valley1_index: valley1[:index],
383
+ valley2_index: valley2[:index],
384
+ valley_price: (valley1[:price] + valley2[:price]) / 2.0,
385
+ peak_price: peak_high,
386
+ strength: peak_rise
387
+ }
388
+ end
389
+
390
+ patterns
391
+ end
392
+
393
+ ##
394
+ # Detect head and shoulders
395
+ #
396
+ def detect_head_shoulders
397
+ peaks = find_peaks
398
+ patterns = []
399
+
400
+ peaks.each_cons(3) do |left, head, right|
401
+ # Head should be higher than shoulders
402
+ next unless head[:price] > left[:price] && head[:price] > right[:price]
403
+
404
+ # Shoulders should be similar height
405
+ shoulder_diff = (left[:price] - right[:price]).abs / left[:price]
406
+ next if shoulder_diff > 0.05
407
+
408
+ patterns << {
409
+ type: :head_and_shoulders,
410
+ left_shoulder: left[:index],
411
+ head: head[:index],
412
+ right_shoulder: right[:index],
413
+ neckline: (left[:price] + right[:price]) / 2.0
414
+ }
415
+ end
416
+
417
+ patterns
418
+ end
419
+
420
+ ##
421
+ # Detect triangle pattern
422
+ #
423
+ def detect_triangle
424
+ # Simplified: look for converging highs and lows
425
+ recent = @prices.last(60)
426
+ return [] if recent.size < 30
427
+
428
+ peaks = []
429
+ valleys = []
430
+
431
+ window = 5
432
+ (window...(recent.size - window)).each do |i|
433
+ left = recent[(i - window)...i]
434
+ right = recent[(i + 1)..(i + window)]
435
+
436
+ if left.all? { |p| recent[i] >= p } && right.all? { |p| recent[i] >= p }
437
+ peaks << { index: i, price: recent[i] }
438
+ elsif left.all? { |p| recent[i] <= p } && right.all? { |p| recent[i] <= p }
439
+ valleys << { index: i, price: recent[i] }
440
+ end
441
+ end
442
+
443
+ return [] if peaks.size < 2 || valleys.size < 2
444
+
445
+ # Check if peaks trending down and valleys trending up
446
+ peak_slope = (peaks.last[:price] - peaks.first[:price]) / (peaks.last[:index] - peaks.first[:index])
447
+ valley_slope = (valleys.last[:price] - valleys.first[:price]) / (valleys.last[:index] - valleys.first[:index])
448
+
449
+ if peak_slope < 0 && valley_slope > 0
450
+ [{
451
+ type: :symmetrical_triangle,
452
+ apex: peaks.last[:index]
453
+ }]
454
+ else
455
+ []
456
+ end
457
+ end
458
+
459
+ ##
460
+ # Find peaks (local maxima)
461
+ #
462
+ def find_peaks
463
+ peaks = []
464
+ window = 5
465
+
466
+ (window...(@prices.size - window)).each do |i|
467
+ left = @prices[(i - window)...i]
468
+ right = @prices[(i + 1)..(i + window)]
469
+
470
+ if left.all? { |p| @prices[i] >= p } && right.all? { |p| @prices[i] >= p }
471
+ peaks << { index: i, price: @prices[i] }
472
+ end
473
+ end
474
+
475
+ peaks
476
+ end
477
+
478
+ ##
479
+ # Find valleys (local minima)
480
+ #
481
+ def find_valleys
482
+ valleys = []
483
+ window = 5
484
+
485
+ (window...(@prices.size - window)).each do |i|
486
+ left = @prices[(i - window)...i]
487
+ right = @prices[(i + 1)..(i + window)]
488
+
489
+ if left.all? { |p| @prices[i] <= p } && right.all? { |p| @prices[i] <= p }
490
+ valleys << { index: i, price: @prices[i] }
491
+ end
492
+ end
493
+
494
+ valleys
495
+ end
496
+ end
497
+ end