Class: SQA::PatternMatcher

Inherits:
Object
  • Object
show all
Defined in:
lib/sqa/pattern_matcher.rb

Overview

PatternMatcher - Find similar historical patterns

Provides methods for:

  • Pattern similarity search (nearest-neighbor)

  • Shape-based pattern matching

  • Predict future moves based on similar past patterns

  • Pattern clustering

Uses techniques:

  • Euclidean distance

  • Dynamic Time Warping (DTW)

  • Pearson correlation

Examples:

Find similar patterns

matcher = SQA::PatternMatcher.new(stock: stock)
similar = matcher.find_similar(lookback: 10, num_matches: 5)
# Returns 5 most similar historical 10-day patterns

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(stock:) ⇒ PatternMatcher

Initialize pattern matcher

Parameters:



32
33
34
35
# File 'lib/sqa/pattern_matcher.rb', line 32

def initialize(stock:)
  @stock = stock
  @prices = stock.df.data["adj_close_price"].to_a
end

Instance Attribute Details

#pricesObject (readonly)

Returns the value of attribute prices.



25
26
27
# File 'lib/sqa/pattern_matcher.rb', line 25

def prices
  @prices
end

#stockObject (readonly)

Returns the value of attribute stock.



25
26
27
# File 'lib/sqa/pattern_matcher.rb', line 25

def stock
  @stock
end

Instance Method Details

#cluster_patterns(pattern_length: 10, num_clusters: 5) ⇒ Array<Array<Hash>>

Cluster patterns by similarity

Parameters:

  • pattern_length (Integer) (defaults to: 10)

    Length of patterns

  • num_clusters (Integer) (defaults to: 5)

    Number of clusters

Returns:

  • (Array<Array<Hash>>)

    Clusters of similar patterns



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/sqa/pattern_matcher.rb', line 163

def cluster_patterns(pattern_length: 10, num_clusters: 5)
  return [] if @prices.size < pattern_length * num_clusters

  # Extract all patterns
  patterns = []
  (@prices.size - pattern_length).times do |start_idx|
    pattern = @prices[start_idx, pattern_length]
    patterns << {
      start_index: start_idx,
      pattern: normalize_pattern(pattern),
      raw_pattern: pattern
    }
  end

  # Simple k-means clustering
  clusters = Array.new(num_clusters) { [] }

  # Initialize centroids randomly
  centroids = patterns.sample(num_clusters).map { |p| p[:pattern] }

  # Iterate until convergence
  10.times do
    # Assign to nearest centroid
    clusters = Array.new(num_clusters) { [] }

    patterns.each do |pattern|
      distances = centroids.map { |centroid| euclidean_distance(pattern[:pattern], centroid) }
      nearest_cluster = distances.index(distances.min)
      clusters[nearest_cluster] << pattern
    end

    # Update centroids
    centroids = clusters.map do |cluster|
      next centroids[0] if cluster.empty?

      # Average pattern
      pattern_length.times.map do |i|
        cluster.map { |p| p[:pattern][i] }.sum / cluster.size.to_f
      end
    end
  end

  clusters.reject(&:empty?)
end

#detect_chart_pattern(pattern_type) ⇒ Array<Hash>

Detect chart patterns (head & shoulders, double top, etc.)

Parameters:

  • pattern_type (Symbol)

    Pattern to detect

Returns:

  • (Array<Hash>)

    Detected patterns



141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/sqa/pattern_matcher.rb', line 141

def detect_chart_pattern(pattern_type)
  case pattern_type
  when :double_top
    detect_double_top
  when :double_bottom
    detect_double_bottom
  when :head_and_shoulders
    detect_head_shoulders
  when :triangle
    detect_triangle
  else
    []
  end
end

#find_similar(lookback: 10, num_matches: 5, method: :euclidean, normalize: true) ⇒ Array<Hash>

Find similar historical patterns to current pattern

Parameters:

  • lookback (Integer) (defaults to: 10)

    Pattern length (days)

  • num_matches (Integer) (defaults to: 5)

    Number of similar patterns to find

  • method (Symbol) (defaults to: :euclidean)

    Distance method (:euclidean, :dtw, :correlation)

  • normalize (Boolean) (defaults to: true)

    Normalize patterns before comparison

Returns:

  • (Array<Hash>)

    Similar patterns with metadata



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/sqa/pattern_matcher.rb', line 46

def find_similar(lookback: 10, num_matches: 5, method: :euclidean, normalize: true)
  return [] if @prices.size < lookback * 2

  # Current pattern (most recent)
  current_pattern = @prices[-lookback..-1]
  current_pattern = normalize_pattern(current_pattern) if normalize

  similarities = []

  # Search through historical data
  (@prices.size - lookback - 20).times do |start_idx|
    next if start_idx + lookback >= @prices.size - lookback  # Don't compare to recent data

    historical_pattern = @prices[start_idx, lookback]
    historical_pattern = normalize_pattern(historical_pattern) if normalize

    distance = case method
               when :euclidean
                 euclidean_distance(current_pattern, historical_pattern)
               when :dtw
                 dtw_distance(current_pattern, historical_pattern)
               when :correlation
                 -correlation(current_pattern, historical_pattern)  # Negative so lower is better
               else
                 euclidean_distance(current_pattern, historical_pattern)
               end

    # What happened next?
    future_start = start_idx + lookback
    future_end = [future_start + lookback, @prices.size - 1].min
    future_prices = @prices[future_start..future_end]

    next if future_prices.empty?

    future_return = (future_prices.last - @prices[start_idx + lookback - 1]) /
                    @prices[start_idx + lookback - 1]

    similarities << {
      start_index: start_idx,
      end_index: start_idx + lookback - 1,
      distance: distance,
      pattern: historical_pattern,
      future_return: future_return,
      future_prices: future_prices,
      pattern_start_price: @prices[start_idx],
      pattern_end_price: @prices[start_idx + lookback - 1]
    }
  end

  # Sort by distance and return top matches
  similarities.sort_by { |s| s[:distance] }.first(num_matches)
end

#forecast(lookback: 10, forecast_periods: 5, num_matches: 10) ⇒ Hash

Predict future price movement based on similar patterns

Parameters:

  • lookback (Integer) (defaults to: 10)

    Pattern length

  • forecast_periods (Integer) (defaults to: 5)

    Periods to forecast

  • num_matches (Integer) (defaults to: 10)

    Number of similar patterns to use

Returns:

  • (Hash)

    Forecast with confidence intervals



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/sqa/pattern_matcher.rb', line 107

def forecast(lookback: 10, forecast_periods: 5, num_matches: 10)
  similar = find_similar(lookback: lookback, num_matches: num_matches)

  return nil if similar.empty?

  # Collect future returns from similar patterns
  future_returns = similar.map { |s| s[:future_return] }

  # Statistical forecast
  mean_return = future_returns.sum / future_returns.size.to_f
  std_return = standard_deviation(future_returns)

  current_price = @prices.last
  forecast_price = current_price * (1 + mean_return)

  {
    forecast_price: forecast_price,
    forecast_return: mean_return,
    confidence_interval_95: [
      current_price * (1 + mean_return - 1.96 * std_return),
      current_price * (1 + mean_return + 1.96 * std_return)
    ],
    num_matches: similar.size,
    similar_patterns: similar,
    current_price: current_price
  }
end

#pattern_quality(pattern) ⇒ Hash

Calculate pattern strength/quality

Parameters:

  • pattern (Array<Float>)

    Price pattern

Returns:

  • (Hash)

    Pattern quality metrics



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/sqa/pattern_matcher.rb', line 214

def pattern_quality(pattern)
  return nil if pattern.size < 3

  # Trend strength (convert to float to avoid integer division)
  first = pattern.first.to_f
  last = pattern.last.to_f
  trend = (last - first) / first

  # Volatility
  returns = pattern.each_cons(2).map { |a, b| (b - a).to_f / a }
  volatility = standard_deviation(returns)

  # Smoothness (how linear is the trend?)
  x_values = (0...pattern.size).to_a
  correlation = pearson_correlation(x_values, pattern)

  {
    trend: trend,
    volatility: volatility,
    smoothness: correlation.abs,
    strength: correlation.abs * (1 - volatility)  # Combined metric
  }
end