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
data/lib/sqa/gp.rb ADDED
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ =begin
4
+
5
+ Genetic Programming for Trading Strategy Evolution
6
+
7
+ This module implements a genetic algorithm to evolve trading strategy parameters.
8
+ It optimizes indicator parameters (like RSI periods, MA lengths, etc.) to find
9
+ profitable trading strategies through natural selection.
10
+
11
+ Key Concepts:
12
+ - Individual: A set of strategy parameters (chromosome)
13
+ - Population: Collection of individuals
14
+ - Fitness: Profitability measured by backtesting
15
+ - Selection: Choosing best individuals to reproduce
16
+ - Crossover: Combining parameters from two parent strategies
17
+ - Mutation: Random parameter changes for diversity
18
+
19
+ Example:
20
+ gp = SQA::GeneticProgram.new(
21
+ stock: stock,
22
+ population_size: 50,
23
+ generations: 100,
24
+ mutation_rate: 0.1
25
+ )
26
+
27
+ best_strategy = gp.evolve do |params|
28
+ # Define how to evaluate fitness with given parameters
29
+ # params might be { indicator: :rsi, period: 14, buy_threshold: 30, sell_threshold: 70 }
30
+ end
31
+
32
+ =end
33
+
34
+ module SQA
35
+ class GeneticProgram
36
+ # Represents an individual trading strategy with specific parameters
37
+ class Individual
38
+ attr_accessor :genes, :fitness
39
+
40
+ def initialize(genes: {}, fitness: nil)
41
+ @genes = genes.dup
42
+ @fitness = fitness
43
+ end
44
+
45
+ def clone
46
+ Individual.new(genes: @genes.dup, fitness: @fitness)
47
+ end
48
+
49
+ def to_s
50
+ "Individual(fitness=#{fitness&.round(2)}, genes=#{genes})"
51
+ end
52
+ end
53
+
54
+ attr_reader :stock, :population, :best_individual, :generation, :history
55
+ attr_accessor :population_size, :generations, :mutation_rate, :crossover_rate, :elitism_count
56
+
57
+ def initialize(stock:, population_size: 50, generations: 100, mutation_rate: 0.15, crossover_rate: 0.7, elitism_count: 2)
58
+ @stock = stock
59
+ @population_size = population_size
60
+ @generations = generations
61
+ @mutation_rate = mutation_rate
62
+ @crossover_rate = crossover_rate
63
+ @elitism_count = elitism_count
64
+
65
+ @population = []
66
+ @best_individual = nil
67
+ @generation = 0
68
+ @history = []
69
+ @gene_constraints = {}
70
+ @fitness_evaluator = nil
71
+ end
72
+
73
+ # Define the parameter space for evolution
74
+ #
75
+ # Example:
76
+ # gp.define_genes(
77
+ # indicator: [:rsi, :macd, :stoch],
78
+ # period: (5..30).to_a,
79
+ # buy_threshold: (20..40).to_a,
80
+ # sell_threshold: (60..80).to_a
81
+ # )
82
+ def define_genes(**constraints)
83
+ @gene_constraints = constraints
84
+ self
85
+ end
86
+
87
+ # Define how to evaluate fitness for an individual
88
+ #
89
+ # The block receives an individual's genes hash and should return
90
+ # a numeric fitness value (higher is better)
91
+ #
92
+ # Example:
93
+ # gp.fitness do |genes|
94
+ # backtest = SQA::Backtest.new(
95
+ # stock: stock,
96
+ # strategy: create_strategy_from_genes(genes),
97
+ # initial_capital: 10_000
98
+ # )
99
+ # results = backtest.run
100
+ # results.total_return # Higher return = higher fitness
101
+ # end
102
+ def fitness(&block)
103
+ @fitness_evaluator = block
104
+ self
105
+ end
106
+
107
+ # Run the genetic algorithm evolution
108
+ def evolve
109
+ raise "Gene constraints not defined. Call define_genes first." if @gene_constraints.empty?
110
+ raise "Fitness evaluator not defined. Call fitness with a block." unless @fitness_evaluator
111
+
112
+ initialize_population
113
+
114
+ @generations.times do |gen|
115
+ @generation = gen + 1
116
+
117
+ # Evaluate fitness for each individual
118
+ evaluate_population
119
+
120
+ # Track best individual
121
+ current_best = @population.max_by(&:fitness)
122
+ if @best_individual.nil? || current_best.fitness > @best_individual.fitness
123
+ @best_individual = current_best.clone
124
+ end
125
+
126
+ # Record history
127
+ avg_fitness = @population.sum(&:fitness) / @population.size.to_f
128
+ @history << {
129
+ generation: @generation,
130
+ best_fitness: current_best.fitness,
131
+ avg_fitness: avg_fitness,
132
+ best_genes: current_best.genes.dup
133
+ }
134
+
135
+ # Print progress
136
+ puts "Generation #{@generation}: Best=#{current_best.fitness.round(2)}%, Avg=#{avg_fitness.round(2)}%"
137
+
138
+ # Create next generation
139
+ @population = create_next_generation
140
+ end
141
+
142
+ # Final evaluation
143
+ evaluate_population
144
+ current_best = @population.max_by(&:fitness)
145
+ if @best_individual.nil? || current_best.fitness > @best_individual.fitness
146
+ @best_individual = current_best.clone
147
+ end
148
+
149
+ puts "\nEvolution complete!"
150
+ puts "Best individual: #{@best_individual}"
151
+
152
+ @best_individual
153
+ end
154
+
155
+ private
156
+
157
+ # Initialize population with random individuals
158
+ def initialize_population
159
+ @population = Array.new(@population_size) do
160
+ Individual.new(genes: random_genes)
161
+ end
162
+ end
163
+
164
+ # Generate random gene values within constraints
165
+ def random_genes
166
+ genes = {}
167
+ @gene_constraints.each do |gene_name, possible_values|
168
+ genes[gene_name] = Array(possible_values).sample
169
+ end
170
+ genes
171
+ end
172
+
173
+ # Evaluate fitness for all individuals in population
174
+ def evaluate_population
175
+ @population.each do |individual|
176
+ next if individual.fitness # Already evaluated
177
+
178
+ begin
179
+ individual.fitness = @fitness_evaluator.call(individual.genes)
180
+ rescue => e
181
+ # If evaluation fails, assign very poor fitness
182
+ individual.fitness = -Float::INFINITY
183
+ puts " Warning: Fitness evaluation failed for #{individual.genes}: #{e.message}"
184
+ end
185
+ end
186
+ end
187
+
188
+ # Create next generation through selection, crossover, and mutation
189
+ def create_next_generation
190
+ new_population = []
191
+
192
+ # Elitism: keep best individuals
193
+ sorted = @population.sort_by(&:fitness).reverse
194
+ @elitism_count.times do |i|
195
+ new_population << sorted[i].clone if sorted[i]
196
+ end
197
+
198
+ # Fill rest of population through crossover and mutation
199
+ while new_population.size < @population_size
200
+ # Selection
201
+ parent1 = tournament_selection
202
+ parent2 = tournament_selection
203
+
204
+ # Crossover
205
+ if rand < @crossover_rate
206
+ child1, child2 = crossover(parent1, parent2)
207
+ else
208
+ child1, child2 = parent1.clone, parent2.clone
209
+ end
210
+
211
+ # Mutation
212
+ mutate(child1) if rand < @mutation_rate
213
+ mutate(child2) if rand < @mutation_rate
214
+
215
+ # Reset fitness for new individuals
216
+ child1.fitness = nil
217
+ child2.fitness = nil
218
+
219
+ new_population << child1
220
+ new_population << child2 if new_population.size < @population_size
221
+ end
222
+
223
+ new_population[0...@population_size]
224
+ end
225
+
226
+ # Tournament selection: pick best from random sample
227
+ def tournament_selection(tournament_size: 3)
228
+ tournament = @population.sample(tournament_size)
229
+ tournament.max_by(&:fitness)
230
+ end
231
+
232
+ # Single-point crossover: combine genes from two parents
233
+ def crossover(parent1, parent2)
234
+ child1_genes = {}
235
+ child2_genes = {}
236
+
237
+ @gene_constraints.keys.each do |gene_name|
238
+ if rand < 0.5
239
+ child1_genes[gene_name] = parent1.genes[gene_name]
240
+ child2_genes[gene_name] = parent2.genes[gene_name]
241
+ else
242
+ child1_genes[gene_name] = parent2.genes[gene_name]
243
+ child2_genes[gene_name] = parent1.genes[gene_name]
244
+ end
245
+ end
246
+
247
+ [Individual.new(genes: child1_genes), Individual.new(genes: child2_genes)]
248
+ end
249
+
250
+ # Mutation: randomly change some genes
251
+ def mutate(individual)
252
+ @gene_constraints.each do |gene_name, possible_values|
253
+ if rand < 0.3 # 30% chance to mutate each gene
254
+ individual.genes[gene_name] = Array(possible_values).sample
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
data/lib/sqa/indicator.rb CHANGED
@@ -1,11 +1,8 @@
1
1
  # lib/sqa/indicator.rb
2
+ # frozen_string_literal: true
2
3
 
3
- class SQA::Indicator
4
- end
4
+ require 'sqa/tai'
5
5
 
6
- # setup a shortcut for the namespace
7
- SQAI = SQA::Indicator
8
-
9
- Dir[__dir__ + "/indicator/*.rb"].each do |file|
10
- load file
11
- end
6
+ # Use SQA::TAI directly for all technical analysis indicators
7
+ # SQAI is a shortcut alias for SQA::TAI
8
+ SQAI = SQA::TAI
data/lib/sqa/init.rb CHANGED
@@ -2,13 +2,8 @@
2
2
 
3
3
  module SQA
4
4
  class << self
5
- @@config = nil
6
- @@av = ApiKeyManager::RateLimited.new(
7
- api_keys: ENV['AV_API_KEYS'],
8
- delay: true,
9
- rate_count: ENV['AV_RATE_CNT'] || 5,
10
- rate_period: ENV['AV_RATE_PER'] || 60
11
- )
5
+ @@config = nil
6
+ @@av_api_key = ENV['AV_API_KEY'] || ENV['ALPHAVANTAGE_API_KEY']
12
7
 
13
8
  # Initializes the SQA modules
14
9
  # returns the configuration
@@ -35,7 +30,19 @@ module SQA
35
30
  config
36
31
  end
37
32
 
38
- def av() = @@av
33
+ def av_api_key
34
+ @@av_api_key || raise('Alpha Vantage API key not set. Set AV_API_KEY or ALPHAVANTAGE_API_KEY environment variable.')
35
+ end
36
+
37
+ # Legacy accessor for backward compatibility
38
+ def av
39
+ self
40
+ end
41
+
42
+ # For compatibility with old SQA.av.key usage
43
+ def key
44
+ av_api_key
45
+ end
39
46
 
40
47
  def debug?() = @@config.debug?
41
48
  def verbose?() = @@config.verbose?
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SQA::MarketRegime - Detect market regimes (bull/bear/sideways)
4
+ #
5
+ # Market regimes are distinct market conditions that affect trading strategy
6
+ # effectiveness. This module identifies regimes based on:
7
+ # - Trend direction (bull/bear)
8
+ # - Volatility levels (VIX, ATR)
9
+ # - Market breadth
10
+ # - Momentum indicators
11
+ #
12
+ # Example:
13
+ # regime = SQA::MarketRegime.detect(stock)
14
+ # # => { type: :bull, volatility: :low, strength: :strong, dates: [...] }
15
+
16
+ module SQA
17
+ module MarketRegime
18
+ class << self
19
+ # Detect current market regime for a stock
20
+ #
21
+ # @param stock [SQA::Stock] Stock to analyze
22
+ # @param lookback [Integer] Days to look back for regime detection
23
+ # @param window [Integer] Alias for lookback (for backward compatibility)
24
+ # @return [Hash] Regime metadata with both symbolic and numeric values
25
+ #
26
+ def detect(stock, lookback: nil, window: nil)
27
+ # Accept both lookback and window for backward compatibility
28
+ # lookback takes precedence if both provided
29
+ period = lookback || window || 60
30
+
31
+ prices = stock.df["adj_close_price"].to_a
32
+ return { type: :unknown } if prices.size < period
33
+
34
+ recent_prices = prices.last(period)
35
+
36
+ # Get both symbolic and numeric classifications
37
+ trend_data = detect_trend_with_score(recent_prices)
38
+ volatility_data = detect_volatility_with_score(recent_prices)
39
+ strength_data = detect_strength_with_score(recent_prices)
40
+
41
+ {
42
+ type: trend_data[:type],
43
+ trend_score: trend_data[:score],
44
+ volatility: volatility_data[:type],
45
+ volatility_score: volatility_data[:score],
46
+ strength: strength_data[:type],
47
+ strength_score: strength_data[:score],
48
+ lookback_days: period,
49
+ detected_at: Time.now
50
+ }
51
+ end
52
+
53
+ # Detect market regimes across entire history
54
+ #
55
+ # Splits historical data into regime periods
56
+ #
57
+ # @param stock [SQA::Stock] Stock to analyze
58
+ # @param window [Integer] Rolling window for regime detection
59
+ # @return [Array<Hash>] Array of regime periods
60
+ #
61
+ def detect_history(stock, window: 60)
62
+ prices = stock.df["adj_close_price"].to_a
63
+ regimes = []
64
+ current_regime = nil
65
+ regime_start = 0
66
+
67
+ (window...prices.size).each do |i|
68
+ window_prices = prices[(i - window)..i]
69
+ regime_type = detect_trend(window_prices)
70
+ volatility = detect_volatility(window_prices)
71
+
72
+ # Check if regime changed
73
+ if current_regime != regime_type
74
+ # Save previous regime
75
+ if current_regime
76
+ regimes << {
77
+ type: current_regime,
78
+ start_index: regime_start,
79
+ end_index: i - 1,
80
+ duration: i - regime_start,
81
+ volatility: volatility
82
+ }
83
+ end
84
+
85
+ current_regime = regime_type
86
+ regime_start = i
87
+ end
88
+ end
89
+
90
+ # Add final regime
91
+ if current_regime
92
+ regimes << {
93
+ type: current_regime,
94
+ start_index: regime_start,
95
+ end_index: prices.size - 1,
96
+ duration: prices.size - regime_start,
97
+ volatility: detect_volatility(prices[regime_start..-1])
98
+ }
99
+ end
100
+
101
+ regimes
102
+ end
103
+
104
+ # Classify regime type based on trend with numeric score
105
+ #
106
+ # @param prices [Array<Float>] Price array
107
+ # @return [Hash] { type: Symbol, score: Float }
108
+ #
109
+ def detect_trend_with_score(prices)
110
+ return { type: :unknown, score: 0.0 } if prices.size < 20
111
+
112
+ # Simple moving averages
113
+ sma_short = prices.last(20).sum / 20.0
114
+ sma_long = prices.last(60).sum / 60.0 rescue sma_short
115
+
116
+ # Price vs moving averages
117
+ current_price = prices.last
118
+
119
+ # Calculate trend strength (percentage above/below SMA)
120
+ pct_above_sma = ((current_price - sma_long) / sma_long * 100.0)
121
+
122
+ if pct_above_sma > 5 && sma_short > sma_long
123
+ { type: :bull, score: pct_above_sma }
124
+ elsif pct_above_sma < -5 && sma_short < sma_long
125
+ { type: :bear, score: pct_above_sma }
126
+ else
127
+ { type: :sideways, score: pct_above_sma }
128
+ end
129
+ end
130
+
131
+ # Classify regime type based on trend (backward compatibility)
132
+ #
133
+ # @param prices [Array<Float>] Price array
134
+ # @return [Symbol] :bull, :bear, or :sideways
135
+ #
136
+ def detect_trend(prices)
137
+ detect_trend_with_score(prices)[:type]
138
+ end
139
+
140
+ # Detect volatility regime with numeric score
141
+ #
142
+ # @param prices [Array<Float>] Price array
143
+ # @return [Hash] { type: Symbol, score: Float }
144
+ #
145
+ def detect_volatility_with_score(prices)
146
+ return { type: :unknown, score: 0.0 } if prices.size < 20
147
+
148
+ # Calculate daily returns
149
+ returns = []
150
+ prices.each_cons(2) do |prev, curr|
151
+ returns << ((curr - prev) / prev * 100.0).abs
152
+ end
153
+
154
+ avg_volatility = returns.sum / returns.size
155
+
156
+ if avg_volatility < 1.0
157
+ { type: :low, score: avg_volatility }
158
+ elsif avg_volatility < 2.5
159
+ { type: :medium, score: avg_volatility }
160
+ else
161
+ { type: :high, score: avg_volatility }
162
+ end
163
+ end
164
+
165
+ # Detect volatility regime (backward compatibility)
166
+ #
167
+ # @param prices [Array<Float>] Price array
168
+ # @return [Symbol] :low, :medium, or :high
169
+ #
170
+ def detect_volatility(prices)
171
+ detect_volatility_with_score(prices)[:type]
172
+ end
173
+
174
+ # Detect trend strength with numeric score
175
+ #
176
+ # @param prices [Array<Float>] Price array
177
+ # @return [Hash] { type: Symbol, score: Float }
178
+ #
179
+ def detect_strength_with_score(prices)
180
+ return { type: :unknown, score: 0.0 } if prices.size < 20
181
+
182
+ # Look at consistency of direction
183
+ up_days = 0
184
+ down_days = 0
185
+
186
+ prices.each_cons(2) do |prev, curr|
187
+ if curr > prev
188
+ up_days += 1
189
+ else
190
+ down_days += 1
191
+ end
192
+ end
193
+
194
+ total_days = up_days + down_days
195
+ directional_pct = [up_days, down_days].max.to_f / total_days * 100
196
+
197
+ if directional_pct > 70
198
+ { type: :strong, score: directional_pct }
199
+ elsif directional_pct > 55
200
+ { type: :moderate, score: directional_pct }
201
+ else
202
+ { type: :weak, score: directional_pct }
203
+ end
204
+ end
205
+
206
+ # Detect trend strength (backward compatibility)
207
+ #
208
+ # @param prices [Array<Float>] Price array
209
+ # @return [Symbol] :weak, :moderate, or :strong
210
+ #
211
+ def detect_strength(prices)
212
+ detect_strength_with_score(prices)[:type]
213
+ end
214
+
215
+ # Split data by regime
216
+ #
217
+ # @param stock [SQA::Stock] Stock to analyze
218
+ # @return [Hash] Data grouped by regime type
219
+ #
220
+ def split_by_regime(stock)
221
+ regimes = detect_history(stock)
222
+ prices = stock.df["adj_close_price"].to_a
223
+
224
+ grouped = { bull: [], bear: [], sideways: [] }
225
+
226
+ regimes.each do |regime|
227
+ regime_prices = prices[regime[:start_index]..regime[:end_index]]
228
+ grouped[regime[:type]] << {
229
+ prices: regime_prices,
230
+ start_index: regime[:start_index],
231
+ end_index: regime[:end_index],
232
+ duration: regime[:duration]
233
+ }
234
+ end
235
+
236
+ grouped
237
+ end
238
+ end
239
+ end
240
+ end