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,947 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
=begin
|
|
4
|
+
|
|
5
|
+
Strategy Generator - Reverse Engineering Profitable Trades
|
|
6
|
+
|
|
7
|
+
This module analyzes historical price data to identify inflection points (turning points)
|
|
8
|
+
that precede significant price movements. It discovers which indicator patterns were
|
|
9
|
+
present at those inflection points.
|
|
10
|
+
|
|
11
|
+
FPOP (Future Period of Performance): The number of days to look ahead from an
|
|
12
|
+
inflection point to measure if the price change exceeds the threshold.
|
|
13
|
+
|
|
14
|
+
Process:
|
|
15
|
+
1. Detect inflection points (local minima for buys, local maxima for sells)
|
|
16
|
+
2. Check if price change during fpop period exceeds threshold percentage
|
|
17
|
+
3. Calculate all indicators at those profitable inflection points
|
|
18
|
+
4. Identify which indicators were "active" (in buy/sell zones)
|
|
19
|
+
5. Find common patterns across profitable trades
|
|
20
|
+
6. Generate trading rules from discovered patterns
|
|
21
|
+
7. Optionally create KBS rules or strategy classes
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
generator = SQA::StrategyGenerator.new(
|
|
25
|
+
stock: stock,
|
|
26
|
+
min_gain_percent: 10.0,
|
|
27
|
+
fpop: 10 # Future Period of Performance (days)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
patterns = generator.discover_patterns
|
|
31
|
+
strategy = generator.generate_strategy
|
|
32
|
+
|
|
33
|
+
=end
|
|
34
|
+
|
|
35
|
+
module SQA
|
|
36
|
+
class StrategyGenerator
|
|
37
|
+
# Represents a profitable trade opportunity discovered in historical data
|
|
38
|
+
class ProfitablePoint
|
|
39
|
+
attr_accessor :entry_index, :entry_price, :exit_index, :exit_price,
|
|
40
|
+
:gain_percent, :holding_days, :indicators,
|
|
41
|
+
:fpl_min_delta, :fpl_max_delta, :fpl_risk, :fpl_direction, :fpl_magnitude
|
|
42
|
+
|
|
43
|
+
def initialize(entry_index:, entry_price:, exit_index:, exit_price:, fpl_data: nil)
|
|
44
|
+
@entry_index = entry_index
|
|
45
|
+
@entry_price = entry_price
|
|
46
|
+
@exit_index = exit_index
|
|
47
|
+
@exit_price = exit_price
|
|
48
|
+
@gain_percent = ((exit_price - entry_price) / entry_price * 100.0)
|
|
49
|
+
@holding_days = exit_index - entry_index
|
|
50
|
+
@indicators = {}
|
|
51
|
+
|
|
52
|
+
# FPL quality metrics
|
|
53
|
+
if fpl_data
|
|
54
|
+
@fpl_min_delta = fpl_data[:min_delta]
|
|
55
|
+
@fpl_max_delta = fpl_data[:max_delta]
|
|
56
|
+
@fpl_risk = fpl_data[:risk]
|
|
57
|
+
@fpl_direction = fpl_data[:direction]
|
|
58
|
+
@fpl_magnitude = fpl_data[:magnitude]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_s
|
|
63
|
+
fpl_info = fpl_direction ? " dir=#{fpl_direction} risk=#{fpl_risk.round(2)}%" : ""
|
|
64
|
+
"ProfitablePoint(gain=#{gain_percent.round(2)}%, days=#{holding_days}, entry=#{entry_index}#{fpl_info})"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Represents a discovered indicator pattern
|
|
69
|
+
class Pattern
|
|
70
|
+
attr_accessor :conditions, :frequency, :avg_gain, :avg_holding_days,
|
|
71
|
+
:success_rate, :occurrences,
|
|
72
|
+
:context
|
|
73
|
+
|
|
74
|
+
def initialize(conditions: {})
|
|
75
|
+
@conditions = conditions
|
|
76
|
+
@frequency = 0
|
|
77
|
+
@avg_gain = 0.0
|
|
78
|
+
@avg_holding_days = 0.0
|
|
79
|
+
@success_rate = 0.0
|
|
80
|
+
@occurrences = []
|
|
81
|
+
@context = PatternContext.new
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_s
|
|
85
|
+
ctx_info = @context.valid? ? " [#{@context.summary}]" : ""
|
|
86
|
+
"Pattern(conditions=#{conditions.size}, freq=#{frequency}, gain=#{avg_gain.round(2)}%, success=#{success_rate.round(2)}%#{ctx_info})"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Pattern Context - metadata about when/where pattern is valid
|
|
91
|
+
class PatternContext
|
|
92
|
+
attr_accessor :market_regime, :valid_months, :valid_quarters,
|
|
93
|
+
:discovered_period, :validation_period,
|
|
94
|
+
:stability_score, :sector, :volatility_regime
|
|
95
|
+
|
|
96
|
+
def initialize
|
|
97
|
+
@market_regime = nil # :bull, :bear, :sideways
|
|
98
|
+
@valid_months = [] # [10, 11, 12, 1] for Q4/Q1
|
|
99
|
+
@valid_quarters = [] # [1, 4] for Q1/Q4
|
|
100
|
+
@discovered_period = nil # "2020-01-01 to 2022-12-31"
|
|
101
|
+
@validation_period = nil # "2023-01-01 to 2024-11-08"
|
|
102
|
+
@stability_score = nil # 0.0-1.0, how consistent over time
|
|
103
|
+
@sector = nil # :technology, :finance, etc.
|
|
104
|
+
@volatility_regime = nil # :low, :medium, :high
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def valid?
|
|
108
|
+
@market_regime || @valid_months.any? || @sector
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def summary
|
|
112
|
+
parts = []
|
|
113
|
+
parts << @market_regime.to_s if @market_regime
|
|
114
|
+
parts << "months:#{@valid_months.join(',')}" if @valid_months.any?
|
|
115
|
+
parts << "Q#{@valid_quarters.join(',')}" if @valid_quarters.any?
|
|
116
|
+
parts << @sector.to_s if @sector
|
|
117
|
+
parts.join(' ')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if pattern is valid for given date and conditions
|
|
121
|
+
def valid_for?(date: nil, regime: nil, sector: nil)
|
|
122
|
+
# Check market regime
|
|
123
|
+
return false if @market_regime && regime && @market_regime != regime
|
|
124
|
+
|
|
125
|
+
# Check sector
|
|
126
|
+
return false if @sector && sector && @sector != sector
|
|
127
|
+
|
|
128
|
+
# Check calendar constraints
|
|
129
|
+
if date
|
|
130
|
+
return false if @valid_months.any? && !@valid_months.include?(date.month)
|
|
131
|
+
|
|
132
|
+
quarter = ((date.month - 1) / 3) + 1
|
|
133
|
+
return false if @valid_quarters.any? && !@valid_quarters.include?(quarter)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
attr_reader :stock, :profitable_points, :patterns, :min_gain_percent,
|
|
141
|
+
:fpop, :min_loss_percent, :indicators_config, :inflection_window,
|
|
142
|
+
:max_fpl_risk, :required_fpl_directions
|
|
143
|
+
|
|
144
|
+
def initialize(stock:, min_gain_percent: 10.0, min_loss_percent: nil, fpop: 10, inflection_window: 3, max_fpl_risk: nil, required_fpl_directions: nil)
|
|
145
|
+
@stock = stock
|
|
146
|
+
@min_gain_percent = min_gain_percent
|
|
147
|
+
@min_loss_percent = min_loss_percent || -min_gain_percent # Symmetric loss threshold
|
|
148
|
+
@fpop = fpop # Future Period of Performance
|
|
149
|
+
@inflection_window = inflection_window # Window for detecting local min/max
|
|
150
|
+
@max_fpl_risk = max_fpl_risk # Optional: Filter by max acceptable risk (volatility)
|
|
151
|
+
@required_fpl_directions = required_fpl_directions # Optional: [:UP, :DOWN, :UNCERTAIN, :FLAT]
|
|
152
|
+
@profitable_points = []
|
|
153
|
+
@patterns = []
|
|
154
|
+
|
|
155
|
+
# Configure which indicators to analyze
|
|
156
|
+
@indicators_config = {
|
|
157
|
+
rsi: { period: 14, oversold: 30, overbought: 70 },
|
|
158
|
+
macd: { fast: 12, slow: 26, signal: 9 },
|
|
159
|
+
stoch: { k_period: 14, d_period: 3, oversold: 20, overbought: 80 },
|
|
160
|
+
sma_cross: { short: 20, long: 50 },
|
|
161
|
+
ema: { period: 20 },
|
|
162
|
+
bbands: { period: 20, nbdev: 2.0 },
|
|
163
|
+
volume: { period: 20, threshold: 1.5 }
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Main entry point: Discover patterns in historical data
|
|
168
|
+
def discover_patterns(min_pattern_frequency: 2)
|
|
169
|
+
puts "=" * 70
|
|
170
|
+
puts "Strategy Generator: Discovering Profitable Patterns"
|
|
171
|
+
puts "=" * 70
|
|
172
|
+
puts "Target gain: ≥#{min_gain_percent}%"
|
|
173
|
+
puts "Target loss: ≤#{min_loss_percent}%"
|
|
174
|
+
puts "FPOP (Future Period of Performance): #{fpop} days"
|
|
175
|
+
puts "Inflection window: #{inflection_window} days"
|
|
176
|
+
puts
|
|
177
|
+
|
|
178
|
+
# Step 1: Find profitable inflection points
|
|
179
|
+
find_profitable_points
|
|
180
|
+
|
|
181
|
+
return [] if @profitable_points.empty?
|
|
182
|
+
|
|
183
|
+
# Step 2: Calculate indicators at each profitable point
|
|
184
|
+
analyze_indicator_states
|
|
185
|
+
|
|
186
|
+
# Step 3: Mine patterns from indicator states
|
|
187
|
+
mine_patterns(min_frequency: min_pattern_frequency)
|
|
188
|
+
|
|
189
|
+
# Step 4: Calculate pattern statistics
|
|
190
|
+
calculate_pattern_statistics
|
|
191
|
+
|
|
192
|
+
@patterns
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Generate a trading strategy from discovered patterns
|
|
196
|
+
def generate_strategy(pattern_index: 0, strategy_type: :proc)
|
|
197
|
+
return nil if @patterns.empty?
|
|
198
|
+
|
|
199
|
+
pattern = @patterns[pattern_index]
|
|
200
|
+
|
|
201
|
+
case strategy_type
|
|
202
|
+
when :proc
|
|
203
|
+
generate_proc_strategy(pattern)
|
|
204
|
+
when :class
|
|
205
|
+
generate_class_strategy(pattern)
|
|
206
|
+
when :kbs
|
|
207
|
+
generate_kbs_strategy(pattern)
|
|
208
|
+
else
|
|
209
|
+
raise "Unknown strategy type: #{strategy_type}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Generate multiple strategies from top N patterns
|
|
214
|
+
def generate_strategies(top_n: 5, strategy_type: :class)
|
|
215
|
+
@patterns.take(top_n).map.with_index do |pattern, i|
|
|
216
|
+
generate_strategy(pattern_index: i, strategy_type: strategy_type)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Print discovered patterns
|
|
221
|
+
def print_patterns(max_patterns: 10)
|
|
222
|
+
puts "\n" + "=" * 70
|
|
223
|
+
puts "Discovered Patterns (Top #{[max_patterns, @patterns.size].min})"
|
|
224
|
+
puts "=" * 70
|
|
225
|
+
|
|
226
|
+
@patterns.take(max_patterns).each_with_index do |pattern, i|
|
|
227
|
+
puts "\nPattern ##{i + 1}:"
|
|
228
|
+
puts " Frequency: #{pattern.frequency} occurrences"
|
|
229
|
+
puts " Average Gain: #{pattern.avg_gain.round(2)}%"
|
|
230
|
+
puts " Average Holding: #{pattern.avg_holding_days.round(1)} days"
|
|
231
|
+
puts " Success Rate: #{pattern.success_rate.round(2)}%"
|
|
232
|
+
puts " Conditions:"
|
|
233
|
+
pattern.conditions.each do |indicator, state|
|
|
234
|
+
puts " - #{indicator}: #{state}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
puts
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Export patterns to CSV
|
|
241
|
+
def export_patterns(filename)
|
|
242
|
+
require 'csv'
|
|
243
|
+
|
|
244
|
+
CSV.open(filename, 'w') do |csv|
|
|
245
|
+
csv << ['Pattern', 'Frequency', 'Avg Gain %', 'Avg Holding Days', 'Success Rate %', 'Conditions']
|
|
246
|
+
|
|
247
|
+
@patterns.each_with_index do |pattern, i|
|
|
248
|
+
conditions_str = pattern.conditions.map { |k, v| "#{k}=#{v}" }.join('; ')
|
|
249
|
+
csv << [
|
|
250
|
+
i + 1,
|
|
251
|
+
pattern.frequency,
|
|
252
|
+
pattern.avg_gain.round(2),
|
|
253
|
+
pattern.avg_holding_days.round(1),
|
|
254
|
+
pattern.success_rate.round(2),
|
|
255
|
+
conditions_str
|
|
256
|
+
]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
puts "Patterns exported to #{filename}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Walk-forward validation - discover patterns with time-series cross-validation
|
|
264
|
+
#
|
|
265
|
+
# Splits data into train/test windows and rolls forward through history
|
|
266
|
+
# to prevent overfitting. Only keeps patterns that work out-of-sample.
|
|
267
|
+
#
|
|
268
|
+
# @param train_size [Integer] Training window size in days
|
|
269
|
+
# @param test_size [Integer] Testing window size in days
|
|
270
|
+
# @param step_size [Integer] How many days to step forward each iteration
|
|
271
|
+
# @return [Hash] Validation results with patterns and performance
|
|
272
|
+
#
|
|
273
|
+
def walk_forward_validate(train_size: 250, test_size: 60, step_size: 30)
|
|
274
|
+
puts "\n" + "=" * 70
|
|
275
|
+
puts "Walk-Forward Validation"
|
|
276
|
+
puts "=" * 70
|
|
277
|
+
puts "Training window: #{train_size} days"
|
|
278
|
+
puts "Testing window: #{test_size} days"
|
|
279
|
+
puts "Step size: #{step_size} days"
|
|
280
|
+
puts
|
|
281
|
+
|
|
282
|
+
prices = @stock.df["adj_close_price"].to_a
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
date_column = @stock.df.data.columns.include?("date") ? "date" : "timestamp"
|
|
286
|
+
dates = @stock.df[date_column].to_a.map { |d| Date.parse(d.to_s) }
|
|
287
|
+
|
|
288
|
+
validated_patterns = []
|
|
289
|
+
validation_results = []
|
|
290
|
+
|
|
291
|
+
start_idx = 0
|
|
292
|
+
iteration = 0
|
|
293
|
+
|
|
294
|
+
while start_idx + train_size + test_size < prices.size
|
|
295
|
+
iteration += 1
|
|
296
|
+
train_start = start_idx
|
|
297
|
+
train_end = start_idx + train_size
|
|
298
|
+
test_start = train_end
|
|
299
|
+
test_end = test_start + test_size
|
|
300
|
+
|
|
301
|
+
puts "\nIteration #{iteration}:"
|
|
302
|
+
puts " Train: #{dates[train_start]} to #{dates[train_end - 1]}"
|
|
303
|
+
puts " Test: #{dates[test_start]} to #{dates[test_end - 1]}"
|
|
304
|
+
|
|
305
|
+
# Create temporary stock with training data
|
|
306
|
+
train_data = create_stock_subset(train_start, train_end)
|
|
307
|
+
|
|
308
|
+
# Discover patterns on training data
|
|
309
|
+
temp_generator = SQA::StrategyGenerator.new(
|
|
310
|
+
stock: train_data,
|
|
311
|
+
min_gain_percent: @min_gain_percent,
|
|
312
|
+
fpop: @fpop,
|
|
313
|
+
inflection_window: @inflection_window,
|
|
314
|
+
max_fpl_risk: @max_fpl_risk,
|
|
315
|
+
required_fpl_directions: @required_fpl_directions
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
train_patterns = temp_generator.discover_patterns(min_pattern_frequency: 2)
|
|
319
|
+
|
|
320
|
+
# Test each pattern on out-of-sample data
|
|
321
|
+
test_data = create_stock_subset(test_start, test_end)
|
|
322
|
+
|
|
323
|
+
train_patterns.each do |pattern|
|
|
324
|
+
# Generate strategy from pattern
|
|
325
|
+
strategy = temp_generator.generate_strategy(
|
|
326
|
+
pattern_index: train_patterns.index(pattern),
|
|
327
|
+
strategy_type: :proc
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Backtest on test period
|
|
331
|
+
begin
|
|
332
|
+
backtest = SQA::Backtest.new(stock: test_data, strategy: strategy)
|
|
333
|
+
results = backtest.run
|
|
334
|
+
|
|
335
|
+
# Store validation result
|
|
336
|
+
validation_results << {
|
|
337
|
+
iteration: iteration,
|
|
338
|
+
pattern: pattern,
|
|
339
|
+
train_period: "#{dates[train_start]} to #{dates[train_end - 1]}",
|
|
340
|
+
test_period: "#{dates[test_start]} to #{dates[test_end - 1]}",
|
|
341
|
+
test_return: results.total_return,
|
|
342
|
+
test_sharpe: results.sharpe_ratio,
|
|
343
|
+
test_max_drawdown: results.max_drawdown
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# Keep pattern if it performed well out-of-sample
|
|
347
|
+
if results.total_return > 0 && results.sharpe_ratio > 0.5
|
|
348
|
+
validated_patterns << pattern
|
|
349
|
+
end
|
|
350
|
+
rescue => e
|
|
351
|
+
puts " Warning: Pattern validation failed: #{e.message}"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
start_idx += step_size
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
puts "\n" + "=" * 70
|
|
359
|
+
puts "Validation Complete"
|
|
360
|
+
puts " Total iterations: #{iteration}"
|
|
361
|
+
puts " Total patterns tested: #{validation_results.size}"
|
|
362
|
+
puts " Patterns validated: #{validated_patterns.size}"
|
|
363
|
+
puts "=" * 70
|
|
364
|
+
|
|
365
|
+
{
|
|
366
|
+
validated_patterns: validated_patterns,
|
|
367
|
+
validation_results: validation_results,
|
|
368
|
+
total_iterations: iteration
|
|
369
|
+
}
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Discover patterns with context (regime, seasonal, sector)
|
|
373
|
+
#
|
|
374
|
+
# @param analyze_regime [Boolean] Detect and filter by market regime
|
|
375
|
+
# @param analyze_seasonal [Boolean] Detect seasonal patterns
|
|
376
|
+
# @param sector [Symbol] Sector classification
|
|
377
|
+
# @return [Array<Pattern>] Patterns with context metadata
|
|
378
|
+
#
|
|
379
|
+
def discover_context_aware_patterns(analyze_regime: true, analyze_seasonal: true, sector: nil)
|
|
380
|
+
puts "\n" + "=" * 70
|
|
381
|
+
puts "Context-Aware Pattern Discovery"
|
|
382
|
+
puts "=" * 70
|
|
383
|
+
|
|
384
|
+
# Step 1: Detect market regime
|
|
385
|
+
if analyze_regime
|
|
386
|
+
regime_data = SQA::MarketRegime.detect(@stock)
|
|
387
|
+
puts "Current regime: #{regime_data[:type]} (#{regime_data[:strength]} strength)"
|
|
388
|
+
|
|
389
|
+
# Split data by regime
|
|
390
|
+
regime_splits = SQA::MarketRegime.split_by_regime(@stock)
|
|
391
|
+
|
|
392
|
+
puts "\nRegime periods:"
|
|
393
|
+
regime_splits.each do |regime, periods|
|
|
394
|
+
total_days = periods.sum { |p| p[:duration] }
|
|
395
|
+
puts " #{regime}: #{total_days} days across #{periods.size} periods"
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Step 2: Analyze seasonality
|
|
400
|
+
if analyze_seasonal
|
|
401
|
+
seasonal_data = SQA::SeasonalAnalyzer.analyze(@stock)
|
|
402
|
+
puts "\nSeasonal analysis:"
|
|
403
|
+
puts " Best months: #{seasonal_data[:best_months].join(', ')}"
|
|
404
|
+
puts " Worst months: #{seasonal_data[:worst_months].join(', ')}"
|
|
405
|
+
puts " Best quarters: Q#{seasonal_data[:best_quarters].join(', Q')}"
|
|
406
|
+
puts " Has seasonal pattern: #{seasonal_data[:has_seasonal_pattern]}"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Step 3: Discover patterns normally
|
|
410
|
+
patterns = discover_patterns
|
|
411
|
+
|
|
412
|
+
# Step 4: Add context to each pattern
|
|
413
|
+
patterns.each do |pattern|
|
|
414
|
+
if analyze_regime
|
|
415
|
+
pattern.context.market_regime = regime_data[:type]
|
|
416
|
+
pattern.context.volatility_regime = regime_data[:volatility]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
if analyze_seasonal && seasonal_data[:has_seasonal_pattern]
|
|
420
|
+
pattern.context.valid_months = seasonal_data[:best_months]
|
|
421
|
+
pattern.context.valid_quarters = seasonal_data[:best_quarters]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
if sector
|
|
425
|
+
pattern.context.sector = sector
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Add discovery period
|
|
429
|
+
|
|
430
|
+
date_column = @stock.df.data.columns.include?("date") ? "date" : "timestamp"
|
|
431
|
+
dates = @stock.df[date_column].to_a
|
|
432
|
+
|
|
433
|
+
pattern.context.discovered_period = "#{dates.first} to #{dates.last}"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
puts "\n" + "=" * 70
|
|
437
|
+
puts "Context-Aware Discovery Complete"
|
|
438
|
+
puts " Patterns found: #{patterns.size}"
|
|
439
|
+
puts " Patterns with context: #{patterns.count { |p| p.context.valid? }}"
|
|
440
|
+
puts "=" * 70
|
|
441
|
+
|
|
442
|
+
patterns
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
private
|
|
446
|
+
|
|
447
|
+
# Step 1: Find all profitable inflection points
|
|
448
|
+
def find_profitable_points
|
|
449
|
+
puts "Step 1: Detecting inflection points and analyzing FPOP..."
|
|
450
|
+
|
|
451
|
+
prices = @stock.df["adj_close_price"].to_a
|
|
452
|
+
|
|
453
|
+
# Step 1a: Calculate FPL analysis for all points
|
|
454
|
+
fpl_analysis = SQA::FPOP.fpl_analysis(prices, fpop: @fpop)
|
|
455
|
+
|
|
456
|
+
# Step 1b: Detect inflection points (local minima and maxima)
|
|
457
|
+
inflection_points = detect_inflection_points(prices)
|
|
458
|
+
puts " Found #{inflection_points.size} inflection points"
|
|
459
|
+
|
|
460
|
+
# Step 1c: Check which inflection points lead to profitable moves
|
|
461
|
+
profitable_count = 0
|
|
462
|
+
filtered_by_risk = 0
|
|
463
|
+
filtered_by_direction = 0
|
|
464
|
+
|
|
465
|
+
inflection_points.each do |inflection_idx|
|
|
466
|
+
# Skip if not enough future data
|
|
467
|
+
next if inflection_idx + @fpop >= prices.size
|
|
468
|
+
next if inflection_idx >= fpl_analysis.size
|
|
469
|
+
|
|
470
|
+
entry_price = prices[inflection_idx]
|
|
471
|
+
fpl_data = fpl_analysis[inflection_idx]
|
|
472
|
+
|
|
473
|
+
# Optional: Filter by FPL risk (volatility)
|
|
474
|
+
if @max_fpl_risk && fpl_data[:risk] > @max_fpl_risk
|
|
475
|
+
filtered_by_risk += 1
|
|
476
|
+
next
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Optional: Filter by FPL direction
|
|
480
|
+
if @required_fpl_directions && !@required_fpl_directions.include?(fpl_data[:direction])
|
|
481
|
+
filtered_by_direction += 1
|
|
482
|
+
next
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Calculate price change over fpop period
|
|
486
|
+
future_prices = prices[(inflection_idx + 1)..(inflection_idx + @fpop)]
|
|
487
|
+
max_future_price = future_prices.max
|
|
488
|
+
min_future_price = future_prices.min
|
|
489
|
+
|
|
490
|
+
# Calculate gain/loss percentages
|
|
491
|
+
max_gain_percent = ((max_future_price - entry_price) / entry_price * 100.0)
|
|
492
|
+
max_loss_percent = ((min_future_price - entry_price) / entry_price * 100.0)
|
|
493
|
+
|
|
494
|
+
# Check if gain exceeds threshold (buy opportunity)
|
|
495
|
+
if max_gain_percent >= @min_gain_percent
|
|
496
|
+
exit_idx = inflection_idx + 1 + future_prices.index(max_future_price)
|
|
497
|
+
|
|
498
|
+
@profitable_points << ProfitablePoint.new(
|
|
499
|
+
entry_index: inflection_idx,
|
|
500
|
+
entry_price: entry_price,
|
|
501
|
+
exit_index: exit_idx,
|
|
502
|
+
exit_price: max_future_price,
|
|
503
|
+
fpl_data: fpl_data
|
|
504
|
+
)
|
|
505
|
+
profitable_count += 1
|
|
506
|
+
# Check if loss exceeds threshold (sell opportunity)
|
|
507
|
+
elsif max_loss_percent <= @min_loss_percent
|
|
508
|
+
exit_idx = inflection_idx + 1 + future_prices.index(min_future_price)
|
|
509
|
+
|
|
510
|
+
@profitable_points << ProfitablePoint.new(
|
|
511
|
+
entry_index: inflection_idx,
|
|
512
|
+
entry_price: entry_price,
|
|
513
|
+
exit_index: exit_idx,
|
|
514
|
+
exit_price: min_future_price,
|
|
515
|
+
fpl_data: fpl_data
|
|
516
|
+
)
|
|
517
|
+
profitable_count += 1
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
puts " Inflection points analyzed: #{inflection_points.size}"
|
|
522
|
+
puts " Filtered by risk: #{filtered_by_risk}" if @max_fpl_risk
|
|
523
|
+
puts " Filtered by direction: #{filtered_by_direction}" if @required_fpl_directions
|
|
524
|
+
puts " Profitable opportunities found: #{@profitable_points.size}"
|
|
525
|
+
if inflection_points.size > 0
|
|
526
|
+
puts " Success rate: #{(@profitable_points.size.to_f / inflection_points.size * 100).round(2)}%"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Print FPL quality stats
|
|
530
|
+
if @profitable_points.any? && @profitable_points.first.fpl_direction
|
|
531
|
+
avg_risk = @profitable_points.map(&:fpl_risk).compact.sum / @profitable_points.size
|
|
532
|
+
avg_magnitude = @profitable_points.map(&:fpl_magnitude).compact.sum / @profitable_points.size
|
|
533
|
+
directions = @profitable_points.map(&:fpl_direction).compact.tally
|
|
534
|
+
puts " Average FPL risk: #{avg_risk.round(2)}%"
|
|
535
|
+
puts " Average FPL magnitude: #{avg_magnitude.round(2)}%"
|
|
536
|
+
puts " Direction distribution: #{directions}"
|
|
537
|
+
end
|
|
538
|
+
puts
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Detect inflection points (local minima and maxima)
|
|
542
|
+
def detect_inflection_points(prices)
|
|
543
|
+
inflection_points = []
|
|
544
|
+
window = @inflection_window
|
|
545
|
+
|
|
546
|
+
# Scan for local minima and maxima
|
|
547
|
+
(window...(prices.size - window)).each do |idx|
|
|
548
|
+
current_price = prices[idx]
|
|
549
|
+
|
|
550
|
+
# Get surrounding window
|
|
551
|
+
left_window = prices[(idx - window)...idx]
|
|
552
|
+
right_window = prices[(idx + 1)..(idx + window)]
|
|
553
|
+
|
|
554
|
+
# Check if local minimum (potential buy point)
|
|
555
|
+
if left_window.all? { |p| current_price <= p } && right_window.all? { |p| current_price <= p }
|
|
556
|
+
inflection_points << idx
|
|
557
|
+
# Check if local maximum (potential sell point)
|
|
558
|
+
elsif left_window.all? { |p| current_price >= p } && right_window.all? { |p| current_price >= p }
|
|
559
|
+
inflection_points << idx
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
inflection_points
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Step 2: Calculate indicator states at each profitable point
|
|
567
|
+
def analyze_indicator_states
|
|
568
|
+
puts "Step 2: Analyzing indicator states at profitable points..."
|
|
569
|
+
|
|
570
|
+
prices = @stock.df["adj_close_price"].to_a
|
|
571
|
+
volumes = @stock.df["volume"].to_a
|
|
572
|
+
highs = @stock.df["high_price"].to_a
|
|
573
|
+
lows = @stock.df["low_price"].to_a
|
|
574
|
+
|
|
575
|
+
# Pre-calculate all indicators for efficiency
|
|
576
|
+
indicator_cache = calculate_all_indicators(prices, volumes, highs, lows)
|
|
577
|
+
|
|
578
|
+
@profitable_points.each do |point|
|
|
579
|
+
idx = point.entry_index
|
|
580
|
+
point.indicators = extract_indicator_states(idx, indicator_cache, prices, volumes)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
puts " Analyzed #{@profitable_points.size} profitable points"
|
|
584
|
+
puts
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Calculate all indicators once for efficiency
|
|
588
|
+
def calculate_all_indicators(prices, volumes, highs, lows)
|
|
589
|
+
cache = {}
|
|
590
|
+
|
|
591
|
+
# RSI
|
|
592
|
+
rsi_config = @indicators_config[:rsi]
|
|
593
|
+
cache[:rsi] = SQAI.rsi(prices, period: rsi_config[:period])
|
|
594
|
+
|
|
595
|
+
# MACD
|
|
596
|
+
macd_config = @indicators_config[:macd]
|
|
597
|
+
macd_line, signal_line, histogram = SQAI.macd(
|
|
598
|
+
prices,
|
|
599
|
+
fast_period: macd_config[:fast],
|
|
600
|
+
slow_period: macd_config[:slow],
|
|
601
|
+
signal_period: macd_config[:signal]
|
|
602
|
+
)
|
|
603
|
+
cache[:macd_line] = macd_line
|
|
604
|
+
cache[:macd_signal] = signal_line
|
|
605
|
+
cache[:macd_histogram] = histogram
|
|
606
|
+
|
|
607
|
+
# Stochastic
|
|
608
|
+
stoch_config = @indicators_config[:stoch]
|
|
609
|
+
stoch_k, stoch_d = SQAI.stoch(
|
|
610
|
+
highs, lows, prices,
|
|
611
|
+
fastk_period: stoch_config[:k_period],
|
|
612
|
+
slowk_period: stoch_config[:d_period],
|
|
613
|
+
slowd_period: stoch_config[:d_period]
|
|
614
|
+
)
|
|
615
|
+
cache[:stoch_k] = stoch_k
|
|
616
|
+
cache[:stoch_d] = stoch_d
|
|
617
|
+
|
|
618
|
+
# SMAs
|
|
619
|
+
sma_config = @indicators_config[:sma_cross]
|
|
620
|
+
cache[:sma_short] = SQAI.sma(prices, period: sma_config[:short])
|
|
621
|
+
cache[:sma_long] = SQAI.sma(prices, period: sma_config[:long])
|
|
622
|
+
|
|
623
|
+
# EMA
|
|
624
|
+
ema_config = @indicators_config[:ema]
|
|
625
|
+
cache[:ema] = SQAI.ema(prices, period: ema_config[:period])
|
|
626
|
+
|
|
627
|
+
# Bollinger Bands
|
|
628
|
+
bb_config = @indicators_config[:bbands]
|
|
629
|
+
upper, middle, lower = SQAI.bbands(
|
|
630
|
+
prices,
|
|
631
|
+
period: bb_config[:period],
|
|
632
|
+
nbdev_up: bb_config[:nbdev],
|
|
633
|
+
nbdev_down: bb_config[:nbdev]
|
|
634
|
+
)
|
|
635
|
+
cache[:bb_upper] = upper
|
|
636
|
+
cache[:bb_middle] = middle
|
|
637
|
+
cache[:bb_lower] = lower
|
|
638
|
+
|
|
639
|
+
cache
|
|
640
|
+
rescue => e
|
|
641
|
+
puts " Warning: Indicator calculation failed: #{e.message}"
|
|
642
|
+
{}
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Extract indicator states at a specific index
|
|
646
|
+
def extract_indicator_states(idx, cache, prices, volumes)
|
|
647
|
+
states = {}
|
|
648
|
+
|
|
649
|
+
# RSI state
|
|
650
|
+
if cache[:rsi] && idx < cache[:rsi].size
|
|
651
|
+
rsi_val = cache[:rsi][idx]
|
|
652
|
+
rsi_config = @indicators_config[:rsi]
|
|
653
|
+
|
|
654
|
+
states[:rsi] = if rsi_val < rsi_config[:oversold]
|
|
655
|
+
:oversold
|
|
656
|
+
elsif rsi_val > rsi_config[:overbought]
|
|
657
|
+
:overbought
|
|
658
|
+
else
|
|
659
|
+
:neutral
|
|
660
|
+
end
|
|
661
|
+
states[:rsi_value] = rsi_val
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# MACD state
|
|
665
|
+
if cache[:macd_line] && cache[:macd_signal] && idx >= 1
|
|
666
|
+
macd_curr = cache[:macd_line][idx]
|
|
667
|
+
signal_curr = cache[:macd_signal][idx]
|
|
668
|
+
macd_prev = cache[:macd_line][idx - 1]
|
|
669
|
+
signal_prev = cache[:macd_signal][idx - 1]
|
|
670
|
+
|
|
671
|
+
states[:macd_crossover] = if macd_prev <= signal_prev && macd_curr > signal_curr
|
|
672
|
+
:bullish
|
|
673
|
+
elsif macd_prev >= signal_prev && macd_curr < signal_curr
|
|
674
|
+
:bearish
|
|
675
|
+
else
|
|
676
|
+
:none
|
|
677
|
+
end
|
|
678
|
+
states[:macd_position] = macd_curr > signal_curr ? :above : :below
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Stochastic state
|
|
682
|
+
if cache[:stoch_k] && idx < cache[:stoch_k].size
|
|
683
|
+
stoch_k_val = cache[:stoch_k][idx]
|
|
684
|
+
stoch_config = @indicators_config[:stoch]
|
|
685
|
+
|
|
686
|
+
states[:stoch] = if stoch_k_val < stoch_config[:oversold]
|
|
687
|
+
:oversold
|
|
688
|
+
elsif stoch_k_val > stoch_config[:overbought]
|
|
689
|
+
:overbought
|
|
690
|
+
else
|
|
691
|
+
:neutral
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# SMA crossover state
|
|
696
|
+
if cache[:sma_short] && cache[:sma_long] && idx < cache[:sma_short].size
|
|
697
|
+
sma_short = cache[:sma_short][idx]
|
|
698
|
+
sma_long = cache[:sma_long][idx]
|
|
699
|
+
|
|
700
|
+
states[:sma_cross] = sma_short > sma_long ? :golden : :death
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Bollinger Bands position
|
|
704
|
+
if cache[:bb_upper] && cache[:bb_lower] && idx < prices.size
|
|
705
|
+
price = prices[idx]
|
|
706
|
+
upper = cache[:bb_upper][idx]
|
|
707
|
+
lower = cache[:bb_lower][idx]
|
|
708
|
+
|
|
709
|
+
states[:bb_position] = if price < lower
|
|
710
|
+
:below_lower
|
|
711
|
+
elsif price > upper
|
|
712
|
+
:above_upper
|
|
713
|
+
else
|
|
714
|
+
:inside
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Price vs EMA
|
|
719
|
+
if cache[:ema] && idx < cache[:ema].size && idx < prices.size
|
|
720
|
+
price = prices[idx]
|
|
721
|
+
ema = cache[:ema][idx]
|
|
722
|
+
|
|
723
|
+
states[:price_vs_ema] = price > ema ? :above : :below
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Volume state
|
|
727
|
+
if idx >= 20 && volumes.size > idx
|
|
728
|
+
current_volume = volumes[idx]
|
|
729
|
+
avg_volume = volumes[(idx - 19)..idx].sum / 20.0
|
|
730
|
+
vol_config = @indicators_config[:volume]
|
|
731
|
+
|
|
732
|
+
states[:volume] = if current_volume > avg_volume * vol_config[:threshold]
|
|
733
|
+
:high
|
|
734
|
+
elsif current_volume < avg_volume * 0.5
|
|
735
|
+
:low
|
|
736
|
+
else
|
|
737
|
+
:normal
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
states
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Step 3: Mine patterns from indicator states
|
|
745
|
+
def mine_patterns(min_frequency: 2)
|
|
746
|
+
puts "Step 3: Mining patterns from indicator states..."
|
|
747
|
+
|
|
748
|
+
pattern_map = Hash.new { |h, k| h[k] = Pattern.new(conditions: k) }
|
|
749
|
+
|
|
750
|
+
# Generate all possible pattern combinations
|
|
751
|
+
@profitable_points.each do |point|
|
|
752
|
+
# Single indicator patterns
|
|
753
|
+
point.indicators.each do |indicator, state|
|
|
754
|
+
key = { indicator => state }
|
|
755
|
+
pattern_map[key].frequency += 1
|
|
756
|
+
pattern_map[key].occurrences << point
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Two-indicator patterns
|
|
760
|
+
indicators = point.indicators.to_a
|
|
761
|
+
indicators.combination(2).each do |combo|
|
|
762
|
+
key = combo.to_h
|
|
763
|
+
pattern_map[key].frequency += 1
|
|
764
|
+
pattern_map[key].occurrences << point
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Three-indicator patterns (for strong signals)
|
|
768
|
+
indicators.combination(3).each do |combo|
|
|
769
|
+
key = combo.to_h
|
|
770
|
+
pattern_map[key].frequency += 1
|
|
771
|
+
pattern_map[key].occurrences << point
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Filter patterns by minimum frequency
|
|
776
|
+
@patterns = pattern_map.values.select { |p| p.frequency >= min_frequency }
|
|
777
|
+
|
|
778
|
+
# Sort by frequency (most common first)
|
|
779
|
+
@patterns.sort_by! { |p| [-p.frequency, -p.conditions.size] }
|
|
780
|
+
|
|
781
|
+
puts " Found #{@patterns.size} patterns (min frequency: #{min_frequency})"
|
|
782
|
+
puts
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Step 4: Calculate pattern statistics
|
|
786
|
+
def calculate_pattern_statistics
|
|
787
|
+
puts "Step 4: Calculating pattern statistics..."
|
|
788
|
+
|
|
789
|
+
@patterns.each do |pattern|
|
|
790
|
+
gains = pattern.occurrences.map(&:gain_percent)
|
|
791
|
+
holding_days = pattern.occurrences.map(&:holding_days)
|
|
792
|
+
|
|
793
|
+
pattern.avg_gain = gains.sum / gains.size.to_f
|
|
794
|
+
pattern.avg_holding_days = holding_days.sum / holding_days.size.to_f
|
|
795
|
+
|
|
796
|
+
# Calculate success rate by backtesting the pattern
|
|
797
|
+
pattern.success_rate = calculate_success_rate(pattern)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Re-sort by success rate and gain
|
|
801
|
+
@patterns.sort_by! { |p| [-p.success_rate, -p.avg_gain, -p.frequency] }
|
|
802
|
+
|
|
803
|
+
puts " Calculated statistics for #{@patterns.size} patterns"
|
|
804
|
+
puts
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# Calculate success rate for a pattern across all history
|
|
808
|
+
def calculate_success_rate(pattern)
|
|
809
|
+
# Simplified: use frequency as proxy for success rate
|
|
810
|
+
# In production, you'd backtest the pattern
|
|
811
|
+
(pattern.frequency.to_f / @profitable_points.size * 100.0)
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Generate a Proc-based strategy
|
|
815
|
+
def generate_proc_strategy(pattern)
|
|
816
|
+
conditions = pattern.conditions.dup
|
|
817
|
+
|
|
818
|
+
lambda do |vector|
|
|
819
|
+
match_count = 0
|
|
820
|
+
total_conditions = conditions.size
|
|
821
|
+
|
|
822
|
+
conditions.each do |indicator, expected_state|
|
|
823
|
+
actual_state = get_indicator_state(vector, indicator)
|
|
824
|
+
match_count += 1 if actual_state == expected_state
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# Require all conditions to match
|
|
828
|
+
match_count == total_conditions ? :buy : :hold
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Generate a Class-based strategy
|
|
833
|
+
def generate_class_strategy(pattern)
|
|
834
|
+
conditions = pattern.conditions.dup
|
|
835
|
+
generator = self
|
|
836
|
+
|
|
837
|
+
Class.new do
|
|
838
|
+
define_singleton_method(:trade) do |vector|
|
|
839
|
+
match_count = 0
|
|
840
|
+
total_conditions = conditions.size
|
|
841
|
+
|
|
842
|
+
conditions.each do |indicator, expected_state|
|
|
843
|
+
actual_state = generator.send(:get_indicator_state, vector, indicator)
|
|
844
|
+
match_count += 1 if actual_state == expected_state
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
match_count == total_conditions ? :buy : :hold
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
define_singleton_method(:pattern) do
|
|
851
|
+
conditions
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Generate a KBS-based strategy
|
|
857
|
+
def generate_kbs_strategy(pattern)
|
|
858
|
+
require_relative 'strategy/kbs_strategy'
|
|
859
|
+
|
|
860
|
+
strategy = SQA::Strategy::KBS.new(load_defaults: false)
|
|
861
|
+
|
|
862
|
+
# Build rule from pattern
|
|
863
|
+
strategy.add_rule :discovered_pattern do
|
|
864
|
+
pattern.conditions.each do |indicator, state|
|
|
865
|
+
on indicator, { state: state }
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
perform do
|
|
869
|
+
assert(:signal, {
|
|
870
|
+
action: :buy,
|
|
871
|
+
confidence: :high,
|
|
872
|
+
reason: :discovered_pattern
|
|
873
|
+
})
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
strategy
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Helper: Create stock subset for walk-forward validation
|
|
881
|
+
def create_stock_subset(start_idx, end_idx)
|
|
882
|
+
# Extract subset of data
|
|
883
|
+
subset_df_data = {}
|
|
884
|
+
|
|
885
|
+
@stock.df.columns.each do |col|
|
|
886
|
+
subset_df_data[col] = @stock.df[col].to_a[start_idx...end_idx]
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Create new stock object with subset
|
|
890
|
+
temp_stock = SQA::Stock.allocate
|
|
891
|
+
temp_stock.instance_variable_set(:@ticker, @stock.ticker)
|
|
892
|
+
temp_stock.instance_variable_set(:@df, SQA::DataFrame.new(subset_df_data))
|
|
893
|
+
|
|
894
|
+
temp_stock
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
# Helper: Get current indicator state from vector
|
|
898
|
+
def get_indicator_state(vector, indicator)
|
|
899
|
+
case indicator
|
|
900
|
+
when :rsi
|
|
901
|
+
return :neutral unless vector.respond_to?(:rsi) && vector.rsi
|
|
902
|
+
rsi_val = Array(vector.rsi).last
|
|
903
|
+
rsi_config = @indicators_config[:rsi]
|
|
904
|
+
if rsi_val < rsi_config[:oversold]
|
|
905
|
+
:oversold
|
|
906
|
+
elsif rsi_val > rsi_config[:overbought]
|
|
907
|
+
:overbought
|
|
908
|
+
else
|
|
909
|
+
:neutral
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
when :macd_crossover
|
|
913
|
+
return :none unless vector.respond_to?(:macd) && vector.macd
|
|
914
|
+
macd_line, signal_line = vector.macd
|
|
915
|
+
return :none if macd_line.size < 2 || signal_line.size < 2
|
|
916
|
+
|
|
917
|
+
macd_curr = macd_line.last
|
|
918
|
+
signal_curr = signal_line.last
|
|
919
|
+
macd_prev = macd_line[-2]
|
|
920
|
+
signal_prev = signal_line[-2]
|
|
921
|
+
|
|
922
|
+
if macd_prev <= signal_prev && macd_curr > signal_curr
|
|
923
|
+
:bullish
|
|
924
|
+
elsif macd_prev >= signal_prev && macd_curr < signal_curr
|
|
925
|
+
:bearish
|
|
926
|
+
else
|
|
927
|
+
:none
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
when :stoch
|
|
931
|
+
return :neutral unless vector.respond_to?(:stoch_k) && vector.stoch_k
|
|
932
|
+
stoch_k_val = Array(vector.stoch_k).last
|
|
933
|
+
stoch_config = @indicators_config[:stoch]
|
|
934
|
+
if stoch_k_val < stoch_config[:oversold]
|
|
935
|
+
:oversold
|
|
936
|
+
elsif stoch_k_val > stoch_config[:overbought]
|
|
937
|
+
:overbought
|
|
938
|
+
else
|
|
939
|
+
:neutral
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
else
|
|
943
|
+
:unknown
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
end
|