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
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
|
-
|
|
4
|
-
end
|
|
4
|
+
require 'sqa/tai'
|
|
5
5
|
|
|
6
|
-
#
|
|
7
|
-
SQAI
|
|
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
|
|
6
|
-
@@
|
|
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
|
|
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
|