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.
- checksums.yaml +4 -4
- data/.goose/memory/development.txt +3 -0
- data/.semver +6 -0
- data/ARCHITECTURE.md +648 -0
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +653 -0
- data/COMMITS.md +196 -0
- data/DATAFRAME_ARCHITECTURE_REVIEW.md +421 -0
- data/NEXT-STEPS.md +154 -0
- data/README.md +812 -262
- data/TASKS.md +358 -0
- data/TEST_RESULTS.md +140 -0
- data/TODO.md +42 -0
- data/_notes.txt +25 -0
- data/bin/sqa-console +11 -0
- data/data/talk_talk.json +103284 -0
- data/develop_summary.md +313 -0
- data/docs/advanced/backtesting.md +206 -0
- data/docs/advanced/ensemble.md +68 -0
- data/docs/advanced/fpop.md +153 -0
- data/docs/advanced/index.md +112 -0
- data/docs/advanced/multi-timeframe.md +67 -0
- data/docs/advanced/pattern-matcher.md +75 -0
- data/docs/advanced/portfolio-optimizer.md +79 -0
- data/docs/advanced/portfolio.md +166 -0
- data/docs/advanced/risk-management.md +210 -0
- data/docs/advanced/strategy-generator.md +158 -0
- data/docs/advanced/streaming.md +209 -0
- data/docs/ai_and_ml.md +80 -0
- data/docs/api/dataframe.md +1115 -0
- data/docs/api/index.md +126 -0
- data/docs/assets/css/custom.css +88 -0
- data/docs/assets/js/mathjax.js +18 -0
- data/docs/concepts/index.md +68 -0
- data/docs/contributing/index.md +60 -0
- data/docs/data-sources/index.md +66 -0
- data/docs/data_frame.md +317 -97
- data/docs/factors_that_impact_price.md +26 -0
- data/docs/finviz.md +11 -0
- data/docs/fx_pro_bit.md +25 -0
- data/docs/genetic_programming.md +104 -0
- data/docs/getting-started/index.md +123 -0
- data/docs/getting-started/installation.md +229 -0
- data/docs/getting-started/quick-start.md +244 -0
- data/docs/i_gotta_an_idea.md +22 -0
- data/docs/index.md +163 -0
- data/docs/indicators/index.md +97 -0
- data/docs/indicators.md +110 -24
- data/docs/options.md +8 -0
- data/docs/strategies/bollinger-bands.md +146 -0
- data/docs/strategies/consensus.md +64 -0
- data/docs/strategies/custom.md +310 -0
- data/docs/strategies/ema.md +53 -0
- data/docs/strategies/index.md +92 -0
- data/docs/strategies/kbs.md +164 -0
- data/docs/strategies/macd.md +96 -0
- data/docs/strategies/market-profile.md +54 -0
- data/docs/strategies/mean-reversion.md +58 -0
- data/docs/strategies/rsi.md +95 -0
- data/docs/strategies/sma.md +55 -0
- data/docs/strategies/stochastic.md +63 -0
- data/docs/strategies/volume-breakout.md +54 -0
- data/docs/tags.md +7 -0
- data/docs/true_strength_index.md +46 -0
- data/docs/weighted_moving_average.md +48 -0
- data/examples/README.md +354 -0
- data/examples/advanced_features_example.rb +350 -0
- data/examples/fpop_analysis_example.rb +191 -0
- data/examples/genetic_programming_example.rb +148 -0
- data/examples/kbs_strategy_example.rb +208 -0
- data/examples/pattern_context_example.rb +300 -0
- data/examples/rails_app/Gemfile +34 -0
- data/examples/rails_app/README.md +416 -0
- data/examples/rails_app/app/assets/javascripts/application.js +107 -0
- data/examples/rails_app/app/assets/stylesheets/application.css +659 -0
- data/examples/rails_app/app/controllers/analysis_controller.rb +11 -0
- data/examples/rails_app/app/controllers/api/v1/stocks_controller.rb +227 -0
- data/examples/rails_app/app/controllers/application_controller.rb +22 -0
- data/examples/rails_app/app/controllers/backtest_controller.rb +11 -0
- data/examples/rails_app/app/controllers/dashboard_controller.rb +21 -0
- data/examples/rails_app/app/controllers/portfolio_controller.rb +7 -0
- data/examples/rails_app/app/views/analysis/show.html.erb +209 -0
- data/examples/rails_app/app/views/backtest/show.html.erb +171 -0
- data/examples/rails_app/app/views/dashboard/index.html.erb +118 -0
- data/examples/rails_app/app/views/dashboard/show.html.erb +408 -0
- data/examples/rails_app/app/views/errors/show.html.erb +17 -0
- data/examples/rails_app/app/views/layouts/application.html.erb +60 -0
- data/examples/rails_app/app/views/portfolio/index.html.erb +33 -0
- data/examples/rails_app/bin/rails +6 -0
- data/examples/rails_app/config/application.rb +45 -0
- data/examples/rails_app/config/boot.rb +5 -0
- data/examples/rails_app/config/database.yml +18 -0
- data/examples/rails_app/config/environment.rb +11 -0
- data/examples/rails_app/config/routes.rb +26 -0
- data/examples/rails_app/config.ru +8 -0
- data/examples/realtime_stream_example.rb +274 -0
- data/examples/sinatra_app/Gemfile +22 -0
- data/examples/sinatra_app/QUICKSTART.md +159 -0
- data/examples/sinatra_app/README.md +461 -0
- data/examples/sinatra_app/app.rb +344 -0
- data/examples/sinatra_app/config.ru +5 -0
- data/examples/sinatra_app/public/css/style.css +659 -0
- data/examples/sinatra_app/public/js/app.js +107 -0
- data/examples/sinatra_app/views/analyze.erb +306 -0
- data/examples/sinatra_app/views/backtest.erb +325 -0
- data/examples/sinatra_app/views/dashboard.erb +419 -0
- data/examples/sinatra_app/views/error.erb +58 -0
- data/examples/sinatra_app/views/index.erb +118 -0
- data/examples/sinatra_app/views/layout.erb +61 -0
- data/examples/sinatra_app/views/portfolio.erb +43 -0
- data/examples/strategy_generator_example.rb +346 -0
- data/hsa_portfolio.csv +11 -0
- data/justfile +0 -0
- data/lib/api/alpha_vantage_api.rb +462 -0
- data/lib/sqa/backtest.rb +329 -0
- data/lib/sqa/data_frame/alpha_vantage.rb +43 -65
- data/lib/sqa/data_frame/data.rb +92 -0
- data/lib/sqa/data_frame/yahoo_finance.rb +35 -43
- data/lib/sqa/data_frame.rb +148 -243
- data/lib/sqa/ensemble.rb +359 -0
- data/lib/sqa/fpop.rb +199 -0
- data/lib/sqa/gp.rb +259 -0
- data/lib/sqa/indicator.rb +5 -8
- data/lib/sqa/init.rb +15 -8
- data/lib/sqa/market_regime.rb +240 -0
- data/lib/sqa/multi_timeframe.rb +379 -0
- data/lib/sqa/pattern_matcher.rb +497 -0
- data/lib/sqa/portfolio.rb +260 -6
- data/lib/sqa/portfolio_optimizer.rb +377 -0
- data/lib/sqa/risk_manager.rb +442 -0
- data/lib/sqa/seasonal_analyzer.rb +209 -0
- data/lib/sqa/sector_analyzer.rb +300 -0
- data/lib/sqa/stock.rb +67 -125
- data/lib/sqa/strategy/bollinger_bands.rb +42 -0
- data/lib/sqa/strategy/consensus.rb +5 -2
- data/lib/sqa/strategy/kbs_strategy.rb +470 -0
- data/lib/sqa/strategy/macd.rb +46 -0
- data/lib/sqa/strategy/mp.rb +1 -1
- data/lib/sqa/strategy/stochastic.rb +60 -0
- data/lib/sqa/strategy/volume_breakout.rb +57 -0
- data/lib/sqa/strategy.rb +5 -0
- data/lib/sqa/strategy_generator.rb +947 -0
- data/lib/sqa/stream.rb +361 -0
- data/lib/sqa/version.rb +1 -7
- data/lib/sqa.rb +23 -16
- data/main.just +81 -0
- data/mkdocs.yml +288 -0
- data/trace.log +0 -0
- metadata +261 -51
- data/bin/sqa +0 -6
- data/lib/patches/dry-cli.rb +0 -228
- data/lib/sqa/activity.rb +0 -10
- data/lib/sqa/cli.rb +0 -62
- data/lib/sqa/commands/analysis.rb +0 -309
- data/lib/sqa/commands/base.rb +0 -139
- data/lib/sqa/commands/web.rb +0 -199
- data/lib/sqa/commands.rb +0 -22
- data/lib/sqa/constants.rb +0 -23
- data/lib/sqa/indicator/average_true_range.rb +0 -33
- data/lib/sqa/indicator/bollinger_bands.rb +0 -28
- data/lib/sqa/indicator/candlestick_pattern_recognizer.rb +0 -60
- data/lib/sqa/indicator/donchian_channel.rb +0 -29
- data/lib/sqa/indicator/double_top_bottom_pattern.rb +0 -34
- data/lib/sqa/indicator/elliott_wave_theory.rb +0 -57
- data/lib/sqa/indicator/exponential_moving_average.rb +0 -25
- data/lib/sqa/indicator/exponential_moving_average_trend.rb +0 -36
- data/lib/sqa/indicator/fibonacci_retracement.rb +0 -23
- data/lib/sqa/indicator/head_and_shoulders_pattern.rb +0 -26
- data/lib/sqa/indicator/market_profile.rb +0 -32
- data/lib/sqa/indicator/mean_reversion.rb +0 -37
- data/lib/sqa/indicator/momentum.rb +0 -28
- data/lib/sqa/indicator/moving_average_convergence_divergence.rb +0 -29
- data/lib/sqa/indicator/peaks_and_valleys.rb +0 -29
- data/lib/sqa/indicator/predict_next_value.rb +0 -202
- data/lib/sqa/indicator/relative_strength_index.rb +0 -47
- data/lib/sqa/indicator/simple_moving_average.rb +0 -24
- data/lib/sqa/indicator/simple_moving_average_trend.rb +0 -32
- data/lib/sqa/indicator/stochastic_oscillator.rb +0 -68
- data/lib/sqa/indicator/true_range.rb +0 -39
- 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
|