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
@@ -0,0 +1,359 @@
1
+ # lib/sqa/ensemble.rb
2
+ # frozen_string_literal: true
3
+
4
+ module SQA
5
+ ##
6
+ # Ensemble - Combine multiple trading strategies
7
+ #
8
+ # Provides methods for:
9
+ # - Majority voting
10
+ # - Weighted voting based on past performance
11
+ # - Meta-learning (strategy selection)
12
+ # - Strategy rotation based on market conditions
13
+ # - Confidence-based aggregation
14
+ #
15
+ # @example Simple majority voting
16
+ # ensemble = SQA::Ensemble.new(
17
+ # strategies: [SQA::Strategy::RSI, SQA::Strategy::MACD, SQA::Strategy::Bollinger]
18
+ # )
19
+ # signal = ensemble.vote(vector)
20
+ # # => :buy (if 2 out of 3 say :buy)
21
+ #
22
+ class Ensemble
23
+ attr_accessor :strategies, :weights, :performance_history
24
+
25
+ ##
26
+ # Initialize ensemble
27
+ #
28
+ # @param strategies [Array<Class>] Array of strategy classes
29
+ # @param voting_method [Symbol] :majority, :weighted, :unanimous, :confidence
30
+ # @param weights [Array<Float>] Optional weights for weighted voting
31
+ #
32
+ def initialize(strategies:, voting_method: :majority, weights: nil)
33
+ @strategies = strategies
34
+ @voting_method = voting_method
35
+ @weights = weights || Array.new(strategies.size, 1.0 / strategies.size)
36
+ @performance_history = Hash.new { |h, k| h[k] = [] }
37
+ @confidence_scores = Hash.new(0.5)
38
+ end
39
+
40
+ ##
41
+ # Generate ensemble signal
42
+ #
43
+ # @param vector [OpenStruct] Market data vector
44
+ # @return [Symbol] :buy, :sell, or :hold
45
+ #
46
+ def signal(vector)
47
+ case @voting_method
48
+ when :majority
49
+ majority_vote(vector)
50
+ when :weighted
51
+ weighted_vote(vector)
52
+ when :unanimous
53
+ unanimous_vote(vector)
54
+ when :confidence
55
+ confidence_vote(vector)
56
+ else
57
+ majority_vote(vector)
58
+ end
59
+ end
60
+
61
+ ##
62
+ # Majority voting
63
+ #
64
+ # @param vector [OpenStruct] Market data
65
+ # @return [Symbol] Signal with most votes
66
+ #
67
+ def majority_vote(vector)
68
+ votes = collect_votes(vector)
69
+
70
+ # Count votes
71
+ vote_counts = { buy: 0, sell: 0, hold: 0 }
72
+ votes.each { |v| vote_counts[v] += 1 }
73
+
74
+ # Return signal with most votes
75
+ vote_counts.max_by { |_signal, count| count }.first
76
+ end
77
+
78
+ ##
79
+ # Weighted voting based on strategy performance
80
+ #
81
+ # @param vector [OpenStruct] Market data
82
+ # @return [Symbol] Weighted signal
83
+ #
84
+ def weighted_vote(vector)
85
+ votes = collect_votes(vector)
86
+
87
+ # Weighted scores
88
+ scores = { buy: 0.0, sell: 0.0, hold: 0.0 }
89
+
90
+ votes.each_with_index do |vote, idx|
91
+ scores[vote] += @weights[idx]
92
+ end
93
+
94
+ # Return signal with highest weighted score
95
+ scores.max_by { |_signal, score| score }.first
96
+ end
97
+
98
+ ##
99
+ # Unanimous voting (all strategies must agree)
100
+ #
101
+ # @param vector [OpenStruct] Market data
102
+ # @return [Symbol] :buy/:sell only if unanimous, otherwise :hold
103
+ #
104
+ def unanimous_vote(vector)
105
+ votes = collect_votes(vector)
106
+
107
+ # All must agree
108
+ if votes.all? { |v| v == :buy }
109
+ :buy
110
+ elsif votes.all? { |v| v == :sell }
111
+ :sell
112
+ else
113
+ :hold
114
+ end
115
+ end
116
+
117
+ ##
118
+ # Confidence-based voting
119
+ #
120
+ # Weight votes by strategy confidence scores.
121
+ #
122
+ # @param vector [OpenStruct] Market data
123
+ # @return [Symbol] Signal weighted by confidence
124
+ #
125
+ def confidence_vote(vector)
126
+ votes = collect_votes(vector)
127
+
128
+ scores = { buy: 0.0, sell: 0.0, hold: 0.0 }
129
+
130
+ votes.each_with_index do |vote, idx|
131
+ strategy_class = @strategies[idx]
132
+ confidence = @confidence_scores[strategy_class] || 0.5
133
+ scores[vote] += confidence
134
+ end
135
+
136
+ scores.max_by { |_signal, score| score }.first
137
+ end
138
+
139
+ ##
140
+ # Update strategy weights based on performance
141
+ #
142
+ # Adjust weights to favor better-performing strategies.
143
+ #
144
+ # @param strategy_index [Integer] Index of strategy
145
+ # @param performance [Float] Performance metric (e.g., return)
146
+ #
147
+ def update_weight(strategy_index, performance)
148
+ @performance_history[@strategies[strategy_index]] << performance
149
+
150
+ # Recalculate weights based on recent performance
151
+ recalculate_weights
152
+ end
153
+
154
+ ##
155
+ # Update confidence score for strategy
156
+ #
157
+ # @param strategy_class [Class] Strategy class
158
+ # @param correct [Boolean] Was the prediction correct?
159
+ #
160
+ def update_confidence(strategy_class, correct)
161
+ current = @confidence_scores[strategy_class]
162
+
163
+ # Exponential moving average of correctness
164
+ alpha = 0.1
165
+ @confidence_scores[strategy_class] = if correct
166
+ current + alpha * (1.0 - current)
167
+ else
168
+ current - alpha * current
169
+ end
170
+ end
171
+
172
+ ##
173
+ # Select best strategy for current market conditions
174
+ #
175
+ # Meta-learning approach: choose the strategy most likely to succeed.
176
+ #
177
+ # @param market_regime [Symbol] Current market regime (:bull, :bear, :sideways)
178
+ # @param volatility [Symbol] Volatility regime (:low, :medium, :high)
179
+ # @return [Class] Best strategy class for conditions
180
+ #
181
+ def select_strategy(market_regime:, volatility: :medium)
182
+ # Strategy performance by market condition
183
+ # This could be learned from historical data
184
+ strategy_preferences = {
185
+ bull: {
186
+ low: SQA::Strategy::EMA,
187
+ medium: SQA::Strategy::MACD,
188
+ high: SQA::Strategy::Bollinger
189
+ },
190
+ bear: {
191
+ low: SQA::Strategy::RSI,
192
+ medium: SQA::Strategy::RSI,
193
+ high: SQA::Strategy::Bollinger
194
+ },
195
+ sideways: {
196
+ low: SQA::Strategy::MR,
197
+ medium: SQA::Strategy::MR,
198
+ high: SQA::Strategy::Bollinger
199
+ }
200
+ }
201
+
202
+ # Return preferred strategy or fall back to best performer
203
+ strategy_preferences.dig(market_regime, volatility) ||
204
+ best_performing_strategy
205
+ end
206
+
207
+ ##
208
+ # Rotate strategies based on market conditions
209
+ #
210
+ # @param stock [SQA::Stock] Stock object
211
+ # @return [Class] Strategy to use
212
+ #
213
+ def rotate(stock)
214
+ regime_data = SQA::MarketRegime.detect(stock)
215
+
216
+ select_strategy(
217
+ market_regime: regime_data[:type],
218
+ volatility: regime_data[:volatility]
219
+ )
220
+ end
221
+
222
+ ##
223
+ # Get ensemble statistics
224
+ #
225
+ # @return [Hash] Performance statistics
226
+ #
227
+ def statistics
228
+ {
229
+ num_strategies: @strategies.size,
230
+ weights: @weights,
231
+ confidence_scores: @confidence_scores,
232
+ best_strategy: best_performing_strategy,
233
+ worst_strategy: worst_performing_strategy,
234
+ performance_history: @performance_history
235
+ }
236
+ end
237
+
238
+ ##
239
+ # Backtest ensemble vs individual strategies
240
+ #
241
+ # @param stock [SQA::Stock] Stock to backtest
242
+ # @param initial_capital [Float] Starting capital
243
+ # @return [Hash] Comparison results
244
+ #
245
+ def backtest_comparison(stock, initial_capital: 10_000)
246
+ results = {}
247
+
248
+ # Backtest ensemble
249
+ ensemble_backtest = SQA::Backtest.new(
250
+ stock: stock,
251
+ strategy: self,
252
+ initial_capital: initial_capital
253
+ )
254
+ results[:ensemble] = ensemble_backtest.run
255
+
256
+ # Backtest each individual strategy
257
+ @strategies.each do |strategy_class|
258
+ individual_backtest = SQA::Backtest.new(
259
+ stock: stock,
260
+ strategy: strategy_class,
261
+ initial_capital: initial_capital
262
+ )
263
+ results[strategy_class.name] = individual_backtest.run
264
+ end
265
+
266
+ results
267
+ end
268
+
269
+ ##
270
+ # Make ensemble compatible with Backtest (acts like a strategy)
271
+ #
272
+ # @param vector [OpenStruct] Market data
273
+ # @return [Symbol] Trading signal
274
+ #
275
+ def self.trade(vector)
276
+ # This won't work for class method, use instance instead
277
+ raise NotImplementedError, "Use ensemble instance, not class"
278
+ end
279
+
280
+ ##
281
+ # Instance method for compatibility
282
+ #
283
+ def trade(vector)
284
+ signal(vector)
285
+ end
286
+
287
+ private
288
+
289
+ ##
290
+ # Collect votes from all strategies
291
+ #
292
+ def collect_votes(vector)
293
+ @strategies.map do |strategy_class|
294
+ begin
295
+ strategy_class.trade(vector)
296
+ rescue StandardError => e
297
+ # If strategy fails, default to :hold
298
+ :hold
299
+ end
300
+ end
301
+ end
302
+
303
+ ##
304
+ # Recalculate weights based on performance history
305
+ #
306
+ def recalculate_weights
307
+ # Use recent performance (last 10 trades)
308
+ recent_performance = @strategies.map do |strategy_class|
309
+ history = @performance_history[strategy_class]
310
+ recent = history.last(10)
311
+ recent.empty? ? 0.0 : recent.sum / recent.size.to_f
312
+ end
313
+
314
+ # Convert to positive values (shift if negative)
315
+ min_perf = recent_performance.min
316
+ if min_perf < 0
317
+ recent_performance = recent_performance.map { |p| p - min_perf + 0.01 }
318
+ end
319
+
320
+ # Normalize to sum to 1.0
321
+ total = recent_performance.sum
322
+ @weights = if total.zero?
323
+ Array.new(@strategies.size, 1.0 / @strategies.size)
324
+ else
325
+ recent_performance.map { |p| p / total }
326
+ end
327
+ end
328
+
329
+ ##
330
+ # Find best performing strategy
331
+ #
332
+ def best_performing_strategy
333
+ return @strategies.first if @performance_history.empty?
334
+
335
+ avg_performance = @strategies.map do |strategy_class|
336
+ history = @performance_history[strategy_class]
337
+ avg = history.empty? ? 0.0 : history.sum / history.size.to_f
338
+ [strategy_class, avg]
339
+ end
340
+
341
+ avg_performance.max_by { |_strategy, avg| avg }&.first || @strategies.first
342
+ end
343
+
344
+ ##
345
+ # Find worst performing strategy
346
+ #
347
+ def worst_performing_strategy
348
+ return @strategies.first if @performance_history.empty?
349
+
350
+ avg_performance = @strategies.map do |strategy_class|
351
+ history = @performance_history[strategy_class]
352
+ avg = history.empty? ? 0.0 : history.sum / history.size.to_f
353
+ [strategy_class, avg]
354
+ end
355
+
356
+ avg_performance.min_by { |_strategy, avg| avg }&.first || @strategies.first
357
+ end
358
+ end
359
+ end
data/lib/sqa/fpop.rb ADDED
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SQA::FPOP - Future Period of Performance Analysis
4
+ #
5
+ # This module provides utilities for analyzing potential future price movements
6
+ # from any given point in a price series. It calculates the range of possible
7
+ # outcomes (min/max deltas) during a future period, along with risk metrics
8
+ # and directional classification.
9
+ #
10
+ # Key Concepts:
11
+ # - FPL (Future Period Loss/Profit): Min and max percentage change during fpop
12
+ # - Risk: Volatility range (max_delta - min_delta)
13
+ # - Direction: Classification of movement (UP/DOWN/UNCERTAIN/FLAT)
14
+ # - Magnitude: Average expected movement
15
+ #
16
+ # Example:
17
+ # prices = [100, 102, 98, 105, 110, 115, 120]
18
+ #
19
+ # # Get raw FPL data
20
+ # fpl_data = SQA::FPOP.fpl(prices, fpop: 3)
21
+ # # => [[-2.0, 10.0], [-4.9, 17.6], ...]
22
+ #
23
+ # # Get detailed analysis
24
+ # analysis = SQA::FPOP.fpl_analysis(prices, fpop: 3)
25
+ # # => [{min_delta: -2.0, max_delta: 10.0, risk: 12.0, direction: :UNCERTAIN, ...}, ...]
26
+
27
+ module SQA
28
+ module FPOP
29
+ class << self
30
+ # Calculate Future Period Loss/Profit for each point in price series
31
+ #
32
+ # For each price point, looks ahead fpop periods and calculates:
33
+ # - Minimum percentage change (worst loss)
34
+ # - Maximum percentage change (best gain)
35
+ #
36
+ # @param price [Array<Numeric>] Array of prices
37
+ # @param fpop [Integer] Future Period of Performance (days to look ahead)
38
+ # @return [Array<Array<Float, Float>>] Array of [min_delta, max_delta] pairs
39
+ #
40
+ # @example
41
+ # SQA::FPOP.fpl([100, 105, 95, 110], fpop: 2)
42
+ # # => [[-5.0, 5.0], [-9.52, 4.76], [15.79, 15.79]]
43
+ #
44
+ def fpl(price, fpop: 14)
45
+ validate_fpl_inputs(price, fpop)
46
+
47
+ price_floats = price.map(&:to_f)
48
+ result = []
49
+
50
+ price_floats.each_with_index do |current_price, index|
51
+ future_prices = price_floats[(index + 1)..(index + fpop)]
52
+ break if future_prices.nil? || future_prices.empty?
53
+
54
+ deltas = future_prices.map { |p| ((p - current_price) / current_price) * 100.0 }
55
+ result << [deltas.min, deltas.max]
56
+ end
57
+
58
+ result
59
+ end
60
+
61
+ # Perform comprehensive FPL analysis with risk metrics and classification
62
+ #
63
+ # @param price [Array<Numeric>] Array of prices
64
+ # @param fpop [Integer] Future Period of Performance
65
+ # @return [Array<Hash>] Array of analysis hashes containing:
66
+ # - :min_delta - Worst percentage loss during fpop
67
+ # - :max_delta - Best percentage gain during fpop
68
+ # - :risk - Volatility range (max - min)
69
+ # - :direction - Movement classification (:UP, :DOWN, :UNCERTAIN, :FLAT)
70
+ # - :magnitude - Average expected movement
71
+ # - :interpretation - Human-readable summary
72
+ #
73
+ # @example
74
+ # analysis = SQA::FPOP.fpl_analysis([100, 110, 120], fpop: 2)
75
+ # analysis.first
76
+ # # => {
77
+ # # min_delta: 10.0,
78
+ # # max_delta: 20.0,
79
+ # # risk: 10.0,
80
+ # # direction: :UP,
81
+ # # magnitude: 15.0,
82
+ # # interpretation: "UP: 15.0% (±5.0% risk)"
83
+ # # }
84
+ #
85
+ def fpl_analysis(price, fpop: 14)
86
+ validate_fpl_inputs(price, fpop)
87
+
88
+ fpl_results = fpl(price, fpop: fpop)
89
+
90
+ fpl_results.map do |min_delta, max_delta|
91
+ {
92
+ min_delta: min_delta,
93
+ max_delta: max_delta,
94
+ risk: (max_delta - min_delta).abs,
95
+ direction: determine_direction(min_delta, max_delta),
96
+ magnitude: calculate_magnitude(min_delta, max_delta),
97
+ interpretation: build_interpretation(min_delta, max_delta)
98
+ }
99
+ end
100
+ end
101
+
102
+ # Determine directional bias from min/max deltas
103
+ #
104
+ # @param min_delta [Float] Minimum percentage change
105
+ # @param max_delta [Float] Maximum percentage change
106
+ # @return [Symbol] :UP, :DOWN, :UNCERTAIN, or :FLAT
107
+ #
108
+ def determine_direction(min_delta, max_delta)
109
+ if min_delta > 0 && max_delta > 0
110
+ :UP
111
+ elsif min_delta < 0 && max_delta < 0
112
+ :DOWN
113
+ elsif min_delta < 0 && max_delta > 0
114
+ :UNCERTAIN
115
+ else
116
+ :FLAT
117
+ end
118
+ end
119
+
120
+ # Calculate average expected movement (magnitude)
121
+ #
122
+ # @param min_delta [Float] Minimum percentage change
123
+ # @param max_delta [Float] Maximum percentage change
124
+ # @return [Float] Average of min and max deltas
125
+ #
126
+ def calculate_magnitude(min_delta, max_delta)
127
+ (min_delta + max_delta) / 2.0
128
+ end
129
+
130
+ # Build human-readable interpretation string
131
+ #
132
+ # @param min_delta [Float] Minimum percentage change
133
+ # @param max_delta [Float] Maximum percentage change
134
+ # @return [String] Formatted interpretation
135
+ #
136
+ def build_interpretation(min_delta, max_delta)
137
+ direction = determine_direction(min_delta, max_delta)
138
+ magnitude = calculate_magnitude(min_delta, max_delta)
139
+ risk = (max_delta - min_delta).abs
140
+
141
+ "#{direction}: #{magnitude.round(2)}% (±#{(risk / 2).round(2)}% risk)"
142
+ end
143
+
144
+ # Filter FPL analysis results by criteria
145
+ #
146
+ # Useful for finding high-quality trading opportunities
147
+ #
148
+ # @param analysis [Array<Hash>] FPL analysis results
149
+ # @param min_magnitude [Float] Minimum average movement (default: nil)
150
+ # @param max_risk [Float] Maximum acceptable risk (default: nil)
151
+ # @param directions [Array<Symbol>] Acceptable directions (default: [:UP, :DOWN, :UNCERTAIN, :FLAT])
152
+ # @return [Array<Integer>] Indices of points that meet criteria
153
+ #
154
+ # @example Find low-risk bullish opportunities
155
+ # analysis = SQA::FPOP.fpl_analysis(prices, fpop: 10)
156
+ # indices = SQA::FPOP.filter_by_quality(
157
+ # analysis,
158
+ # min_magnitude: 5.0,
159
+ # max_risk: 10.0,
160
+ # directions: [:UP]
161
+ # )
162
+ #
163
+ def filter_by_quality(analysis, min_magnitude: nil, max_risk: nil, directions: [:UP, :DOWN, :UNCERTAIN, :FLAT])
164
+ indices = []
165
+
166
+ analysis.each_with_index do |result, idx|
167
+ next if min_magnitude && result[:magnitude] < min_magnitude
168
+ next if max_risk && result[:risk] > max_risk
169
+ next unless directions.include?(result[:direction])
170
+
171
+ indices << idx
172
+ end
173
+
174
+ indices
175
+ end
176
+
177
+ # Calculate risk-reward ratio for each analysis point
178
+ #
179
+ # @param analysis [Array<Hash>] FPL analysis results
180
+ # @return [Array<Float>] Risk-reward ratios (magnitude / risk)
181
+ #
182
+ def risk_reward_ratios(analysis)
183
+ analysis.map do |result|
184
+ result[:risk] > 0 ? result[:magnitude].abs / result[:risk] : 0.0
185
+ end
186
+ end
187
+
188
+ private
189
+
190
+ # Validate FPL input parameters
191
+ def validate_fpl_inputs(price, fpop)
192
+ raise ArgumentError, "price must be an Array" unless price.is_a?(Array)
193
+ raise ArgumentError, "price cannot be empty" if price.empty?
194
+ raise ArgumentError, "fpop must be a positive integer" unless fpop.is_a?(Integer) && fpop > 0
195
+ raise ArgumentError, "prices must not contain zero or negative values" if price.any? { |p| p <= 0 }
196
+ end
197
+ end
198
+ end
199
+ end