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,442 @@
1
+ # lib/sqa/risk_manager.rb
2
+ # frozen_string_literal: true
3
+
4
+ module SQA
5
+ ##
6
+ # RiskManager - Comprehensive risk management and position sizing
7
+ #
8
+ # Provides methods for:
9
+ # - Value at Risk (VaR): Historical, Parametric, Monte Carlo
10
+ # - Conditional VaR (CVaR / Expected Shortfall)
11
+ # - Position sizing: Kelly Criterion, Fixed Fractional, Percent Volatility
12
+ # - Risk metrics: Sharpe, Sortino, Calmar, Maximum Drawdown
13
+ # - Stop loss calculations
14
+ #
15
+ # @example Basic VaR calculation
16
+ # returns = stock.df["adj_close_price"].to_a.each_cons(2).map { |a, b| (b - a) / a }
17
+ # var_95 = SQA::RiskManager.var(returns, confidence: 0.95)
18
+ # puts "95% VaR: #{var_95}%"
19
+ #
20
+ # @example Position sizing with Kelly Criterion
21
+ # position = SQA::RiskManager.kelly_criterion(
22
+ # win_rate: 0.55,
23
+ # avg_win: 0.15,
24
+ # avg_loss: 0.10,
25
+ # capital: 10_000
26
+ # )
27
+ # puts "Optimal position size: $#{position}"
28
+ #
29
+ class RiskManager
30
+ class << self
31
+ ##
32
+ # Calculate Value at Risk (VaR) using historical method
33
+ #
34
+ # VaR represents the maximum expected loss over a given time period
35
+ # at a specified confidence level.
36
+ #
37
+ # @param returns [Array<Float>] Array of period returns (e.g., daily returns)
38
+ # @param confidence [Float] Confidence level (default: 0.95 for 95%)
39
+ # @param method [Symbol] Method to use (:historical, :parametric, :monte_carlo)
40
+ # @param simulations [Integer] Number of Monte Carlo simulations (if method is :monte_carlo)
41
+ # @return [Float] Value at Risk as a percentage
42
+ #
43
+ # @example
44
+ # returns = [0.01, -0.02, 0.015, -0.01, 0.005]
45
+ # var = SQA::RiskManager.var(returns, confidence: 0.95)
46
+ # # => -0.02 (2% maximum expected loss at 95% confidence)
47
+ #
48
+ def var(returns, confidence: 0.95, method: :historical, simulations: 10_000)
49
+ return nil if returns.empty?
50
+
51
+ case method
52
+ when :historical
53
+ historical_var(returns, confidence)
54
+ when :parametric
55
+ parametric_var(returns, confidence)
56
+ when :monte_carlo
57
+ monte_carlo_var(returns, confidence, simulations)
58
+ else
59
+ raise ArgumentError, "Unknown VaR method: #{method}"
60
+ end
61
+ end
62
+
63
+ ##
64
+ # Calculate Conditional Value at Risk (CVaR / Expected Shortfall)
65
+ #
66
+ # CVaR is the expected loss given that the loss exceeds the VaR threshold.
67
+ # It provides a more conservative risk measure than VaR.
68
+ #
69
+ # @param returns [Array<Float>] Array of period returns
70
+ # @param confidence [Float] Confidence level (default: 0.95)
71
+ # @return [Float] CVaR as a percentage
72
+ #
73
+ # @example
74
+ # cvar = SQA::RiskManager.cvar(returns, confidence: 0.95)
75
+ # # => -0.025 (2.5% expected loss in worst 5% of cases)
76
+ #
77
+ def cvar(returns, confidence: 0.95)
78
+ return nil if returns.empty?
79
+
80
+ var_threshold = var(returns, confidence: confidence, method: :historical)
81
+ tail_losses = returns.select { |r| r <= var_threshold }
82
+
83
+ return nil if tail_losses.empty?
84
+ tail_losses.sum / tail_losses.size.to_f
85
+ end
86
+
87
+ ##
88
+ # Calculate position size using Kelly Criterion
89
+ #
90
+ # Kelly Criterion calculates the optimal fraction of capital to risk
91
+ # based on win rate and win/loss ratio.
92
+ #
93
+ # Formula: f = (p * b - q) / b
94
+ # where:
95
+ # f = fraction of capital to bet
96
+ # p = probability of winning
97
+ # q = probability of losing (1 - p)
98
+ # b = win/loss ratio (avg_win / avg_loss)
99
+ #
100
+ # @param win_rate [Float] Win rate (0.0 to 1.0)
101
+ # @param avg_win [Float] Average win size (as percentage)
102
+ # @param avg_loss [Float] Average loss size (as percentage)
103
+ # @param capital [Float] Total capital available
104
+ # @param max_fraction [Float] Maximum fraction to risk (default: 0.25 for 25%)
105
+ # @return [Float] Dollar amount to risk
106
+ #
107
+ # @example
108
+ # position = SQA::RiskManager.kelly_criterion(
109
+ # win_rate: 0.60,
110
+ # avg_win: 0.10,
111
+ # avg_loss: 0.05,
112
+ # capital: 10_000,
113
+ # max_fraction: 0.25
114
+ # )
115
+ #
116
+ def kelly_criterion(win_rate:, avg_win:, avg_loss:, capital:, max_fraction: 0.25)
117
+ return 0.0 if avg_loss.zero? || win_rate >= 1.0 || win_rate <= 0.0
118
+
119
+ lose_rate = 1.0 - win_rate
120
+ win_loss_ratio = avg_win / avg_loss
121
+
122
+ # Kelly formula
123
+ kelly_fraction = (win_rate * win_loss_ratio - lose_rate) / win_loss_ratio
124
+
125
+ # Cap at max_fraction (Kelly can be aggressive)
126
+ kelly_fraction = [kelly_fraction, max_fraction].min
127
+ kelly_fraction = [kelly_fraction, 0.0].max # No negative positions
128
+
129
+ capital * kelly_fraction
130
+ end
131
+
132
+ ##
133
+ # Calculate position size using Fixed Fractional method
134
+ #
135
+ # Risk a fixed percentage of capital on each trade.
136
+ # Simple and conservative approach.
137
+ #
138
+ # @param capital [Float] Total capital
139
+ # @param risk_fraction [Float] Fraction to risk (e.g., 0.02 for 2%)
140
+ # @return [Float] Dollar amount to risk
141
+ #
142
+ # @example
143
+ # position = SQA::RiskManager.fixed_fractional(capital: 10_000, risk_fraction: 0.02)
144
+ # # => 200.0 (risk $200 per trade)
145
+ #
146
+ def fixed_fractional(capital:, risk_fraction: 0.02)
147
+ capital * risk_fraction
148
+ end
149
+
150
+ ##
151
+ # Calculate position size using Percent Volatility method
152
+ #
153
+ # Adjust position size based on recent volatility.
154
+ # Higher volatility = smaller position size.
155
+ #
156
+ # @param capital [Float] Total capital
157
+ # @param returns [Array<Float>] Recent returns
158
+ # @param target_volatility [Float] Target portfolio volatility (annualized)
159
+ # @param current_price [Float] Current asset price
160
+ # @return [Integer] Number of shares to buy
161
+ #
162
+ # @example
163
+ # shares = SQA::RiskManager.percent_volatility(
164
+ # capital: 10_000,
165
+ # returns: recent_returns,
166
+ # target_volatility: 0.15,
167
+ # current_price: 150.0
168
+ # )
169
+ #
170
+ def percent_volatility(capital:, returns:, target_volatility: 0.15, current_price:)
171
+ return 0 if returns.empty? || current_price.zero?
172
+
173
+ # Calculate recent volatility (annualized)
174
+ std_dev = standard_deviation(returns)
175
+ annualized_volatility = std_dev * Math.sqrt(252) # Assume 252 trading days
176
+
177
+ return 0 if annualized_volatility.zero?
178
+
179
+ # Calculate position size
180
+ position_value = capital * (target_volatility / annualized_volatility)
181
+ shares = (position_value / current_price).floor
182
+
183
+ [shares, 0].max # No negative shares
184
+ end
185
+
186
+ ##
187
+ # Calculate stop loss price based on ATR (Average True Range)
188
+ #
189
+ # @param current_price [Float] Current asset price
190
+ # @param atr [Float] Average True Range
191
+ # @param multiplier [Float] ATR multiplier (default: 2.0)
192
+ # @param direction [Symbol] :long or :short
193
+ # @return [Float] Stop loss price
194
+ #
195
+ # @example
196
+ # stop = SQA::RiskManager.atr_stop_loss(
197
+ # current_price: 150.0,
198
+ # atr: 3.5,
199
+ # multiplier: 2.0,
200
+ # direction: :long
201
+ # )
202
+ # # => 143.0 (stop at current - 2*ATR)
203
+ #
204
+ def atr_stop_loss(current_price:, atr:, multiplier: 2.0, direction: :long)
205
+ if direction == :long
206
+ current_price - (atr * multiplier)
207
+ else
208
+ current_price + (atr * multiplier)
209
+ end
210
+ end
211
+
212
+ ##
213
+ # Calculate maximum drawdown from price series
214
+ #
215
+ # Drawdown is the peak-to-trough decline in portfolio value.
216
+ #
217
+ # @param prices [Array<Float>] Array of prices or portfolio values
218
+ # @return [Hash] { max_drawdown: Float, peak_idx: Integer, trough_idx: Integer }
219
+ #
220
+ # @example
221
+ # dd = SQA::RiskManager.max_drawdown([100, 110, 105, 95, 100])
222
+ # # => { max_drawdown: -0.136, peak_idx: 1, trough_idx: 3 }
223
+ #
224
+ def max_drawdown(prices)
225
+ return { max_drawdown: 0.0, peak_idx: 0, trough_idx: 0 } if prices.size < 2
226
+
227
+ max_dd = 0.0
228
+ peak_idx = 0
229
+ trough_idx = 0
230
+ running_peak = prices.first
231
+ running_peak_idx = 0
232
+
233
+ prices.each_with_index do |price, idx|
234
+ if price > running_peak
235
+ running_peak = price
236
+ running_peak_idx = idx
237
+ end
238
+
239
+ drawdown = (price - running_peak) / running_peak
240
+ if drawdown < max_dd
241
+ max_dd = drawdown
242
+ peak_idx = running_peak_idx
243
+ trough_idx = idx
244
+ end
245
+ end
246
+
247
+ {
248
+ max_drawdown: max_dd,
249
+ peak_idx: peak_idx,
250
+ trough_idx: trough_idx,
251
+ peak_value: prices[peak_idx],
252
+ trough_value: prices[trough_idx]
253
+ }
254
+ end
255
+
256
+ ##
257
+ # Calculate Sharpe Ratio
258
+ #
259
+ # Measures risk-adjusted return (excess return per unit of risk).
260
+ #
261
+ # @param returns [Array<Float>] Array of period returns
262
+ # @param risk_free_rate [Float] Risk-free rate (annualized, default: 0.02)
263
+ # @param periods_per_year [Integer] Number of periods per year (default: 252 for daily)
264
+ # @return [Float] Sharpe ratio
265
+ #
266
+ # @example
267
+ # sharpe = SQA::RiskManager.sharpe_ratio(returns, risk_free_rate: 0.02)
268
+ #
269
+ def sharpe_ratio(returns, risk_free_rate: 0.02, periods_per_year: 252)
270
+ return 0.0 if returns.empty?
271
+
272
+ excess_returns = returns.map { |r| r - (risk_free_rate / periods_per_year) }
273
+ mean_excess = excess_returns.sum / excess_returns.size.to_f
274
+ std_excess = standard_deviation(excess_returns)
275
+
276
+ return 0.0 if std_excess.zero?
277
+
278
+ (mean_excess / std_excess) * Math.sqrt(periods_per_year)
279
+ end
280
+
281
+ ##
282
+ # Calculate Sortino Ratio
283
+ #
284
+ # Like Sharpe ratio but only penalizes downside volatility.
285
+ #
286
+ # @param returns [Array<Float>] Array of period returns
287
+ # @param target_return [Float] Target return (default: 0.0)
288
+ # @param periods_per_year [Integer] Number of periods per year (default: 252)
289
+ # @return [Float] Sortino ratio
290
+ #
291
+ # @example
292
+ # sortino = SQA::RiskManager.sortino_ratio(returns)
293
+ #
294
+ def sortino_ratio(returns, target_return: 0.0, periods_per_year: 252)
295
+ return 0.0 if returns.empty?
296
+
297
+ excess_returns = returns.map { |r| r - target_return }
298
+ mean_excess = excess_returns.sum / excess_returns.size.to_f
299
+
300
+ # Downside deviation (only negative returns)
301
+ downside_returns = excess_returns.select { |r| r < 0 }
302
+ return Float::INFINITY if downside_returns.empty?
303
+
304
+ downside_deviation = Math.sqrt(
305
+ downside_returns.map { |r| r**2 }.sum / downside_returns.size.to_f
306
+ )
307
+
308
+ return 0.0 if downside_deviation.zero?
309
+
310
+ (mean_excess / downside_deviation) * Math.sqrt(periods_per_year)
311
+ end
312
+
313
+ ##
314
+ # Calculate Calmar Ratio
315
+ #
316
+ # Ratio of annualized return to maximum drawdown.
317
+ #
318
+ # @param returns [Array<Float>] Array of period returns
319
+ # @param periods_per_year [Integer] Number of periods per year (default: 252)
320
+ # @return [Float] Calmar ratio
321
+ #
322
+ # @example
323
+ # calmar = SQA::RiskManager.calmar_ratio(returns)
324
+ #
325
+ def calmar_ratio(returns, periods_per_year: 252)
326
+ return 0.0 if returns.empty?
327
+
328
+ # Annualized return
329
+ total_return = returns.inject(1.0) { |product, r| product * (1 + r) }
330
+ periods = returns.size
331
+ annualized_return = (total_return ** (periods_per_year.to_f / periods)) - 1.0
332
+
333
+ # Convert returns to prices for drawdown calculation
334
+ prices = returns.inject([100.0]) { |acc, r| acc << acc.last * (1 + r) }
335
+ max_dd = max_drawdown(prices)[:max_drawdown].abs
336
+
337
+ return 0.0 if max_dd.zero?
338
+
339
+ annualized_return / max_dd
340
+ end
341
+
342
+ ##
343
+ # Monte Carlo simulation for portfolio value
344
+ #
345
+ # @param initial_capital [Float] Starting capital
346
+ # @param returns [Array<Float>] Historical returns to sample from
347
+ # @param periods [Integer] Number of periods to simulate
348
+ # @param simulations [Integer] Number of simulation paths
349
+ # @return [Hash] Simulation results with percentiles
350
+ #
351
+ # @example
352
+ # results = SQA::RiskManager.monte_carlo_simulation(
353
+ # initial_capital: 10_000,
354
+ # returns: historical_returns,
355
+ # periods: 252,
356
+ # simulations: 1000
357
+ # )
358
+ # puts "95th percentile: $#{results[:percentile_95]}"
359
+ #
360
+ def monte_carlo_simulation(initial_capital:, returns:, periods:, simulations: 1000)
361
+ return nil if returns.empty?
362
+
363
+ final_values = simulations.times.map do
364
+ value = initial_capital
365
+ periods.times do
366
+ random_return = returns.sample
367
+ value *= (1 + random_return)
368
+ end
369
+ value
370
+ end
371
+
372
+ final_values.sort!
373
+
374
+ {
375
+ mean: final_values.sum / final_values.size.to_f,
376
+ median: final_values[final_values.size / 2],
377
+ percentile_5: final_values[(final_values.size * 0.05).floor],
378
+ percentile_25: final_values[(final_values.size * 0.25).floor],
379
+ percentile_75: final_values[(final_values.size * 0.75).floor],
380
+ percentile_95: final_values[(final_values.size * 0.95).floor],
381
+ min: final_values.first,
382
+ max: final_values.last,
383
+ all_values: final_values
384
+ }
385
+ end
386
+
387
+ private
388
+
389
+ ##
390
+ # Historical VaR calculation
391
+ def historical_var(returns, confidence)
392
+ sorted = returns.sort
393
+ index = ((1 - confidence) * sorted.size).floor
394
+ sorted[index]
395
+ end
396
+
397
+ ##
398
+ # Parametric VaR (assumes normal distribution)
399
+ def parametric_var(returns, confidence)
400
+ mean = returns.sum / returns.size.to_f
401
+ std_dev = standard_deviation(returns)
402
+
403
+ # Z-score for confidence level
404
+ z_score = {
405
+ 0.90 => -1.28,
406
+ 0.95 => -1.645,
407
+ 0.99 => -2.33
408
+ }[confidence] || -1.645
409
+
410
+ mean + (z_score * std_dev)
411
+ end
412
+
413
+ ##
414
+ # Monte Carlo VaR
415
+ def monte_carlo_var(returns, confidence, simulations)
416
+ mean = returns.sum / returns.size.to_f
417
+ std_dev = standard_deviation(returns)
418
+
419
+ # Generate random returns
420
+ simulated = simulations.times.map do
421
+ # Box-Muller transform for normal distribution
422
+ u1 = rand
423
+ u2 = rand
424
+ z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math::PI * u2)
425
+ mean + (std_dev * z)
426
+ end
427
+
428
+ historical_var(simulated, confidence)
429
+ end
430
+
431
+ ##
432
+ # Calculate standard deviation
433
+ def standard_deviation(values)
434
+ return 0.0 if values.empty?
435
+
436
+ mean = values.sum / values.size.to_f
437
+ variance = values.map { |v| (v - mean)**2 }.sum / values.size.to_f
438
+ Math.sqrt(variance)
439
+ end
440
+ end
441
+ end
442
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SQA::SeasonalAnalyzer - Detect seasonal and cyclical patterns
4
+ #
5
+ # Many stocks exhibit seasonal behavior:
6
+ # - Retail stocks: Holiday shopping (Q4)
7
+ # - Tax prep: Q1/Q4 surge
8
+ # - Energy: Summer driving season
9
+ # - Agriculture: Planting/harvest cycles
10
+ #
11
+ # This module identifies which months/quarters patterns are valid.
12
+ #
13
+ # Example:
14
+ # seasonal = SQA::SeasonalAnalyzer.analyze(stock)
15
+ # # => { best_months: [10, 11, 12], worst_months: [5, 6], ... }
16
+
17
+ module SQA
18
+ module SeasonalAnalyzer
19
+ class << self
20
+ # Analyze seasonal performance patterns
21
+ #
22
+ # @param stock [SQA::Stock] Stock to analyze
23
+ # @return [Hash] Seasonal performance metadata
24
+ #
25
+ def analyze(stock)
26
+ df = stock.df
27
+
28
+
29
+ # Extract dates and prices (handle both 'date' and 'timestamp' column names)
30
+ date_column = df.data.columns.include?("date") ? "date" : "timestamp"
31
+ dates = df[date_column].to_a.map { |d| Date.parse(d.to_s) }
32
+
33
+ prices = df["adj_close_price"].to_a
34
+
35
+ # Calculate monthly returns
36
+ monthly_returns = calculate_monthly_returns(dates, prices)
37
+ quarterly_returns = calculate_quarterly_returns(dates, prices)
38
+
39
+ {
40
+ monthly_returns: monthly_returns,
41
+ quarterly_returns: quarterly_returns,
42
+ best_months: rank_months(monthly_returns).first(3),
43
+ worst_months: rank_months(monthly_returns).last(3),
44
+ best_quarters: rank_quarters(quarterly_returns).first(2),
45
+ worst_quarters: rank_quarters(quarterly_returns).last(2),
46
+ has_seasonal_pattern: detect_seasonality(monthly_returns)
47
+ }
48
+ end
49
+
50
+ # Filter data by calendar months
51
+ #
52
+ # @param stock [SQA::Stock] Stock to analyze
53
+ # @param months [Array<Integer>] Months to include (1-12)
54
+ # @return [Hash] Filtered data
55
+ #
56
+ def filter_by_months(stock, months)
57
+ df = stock.df
58
+
59
+ date_column = df.data.columns.include?("date") ? "date" : "timestamp"
60
+ dates = df[date_column].to_a.map { |d| Date.parse(d.to_s) }
61
+
62
+ prices = df["adj_close_price"].to_a
63
+
64
+ indices = []
65
+ dates.each_with_index do |date, i|
66
+ indices << i if months.include?(date.month)
67
+ end
68
+
69
+ {
70
+ indices: indices,
71
+ dates: indices.map { |i| dates[i] },
72
+ prices: indices.map { |i| prices[i] }
73
+ }
74
+ end
75
+
76
+ # Filter data by quarters
77
+ #
78
+ # @param stock [SQA::Stock] Stock to analyze
79
+ # @param quarters [Array<Integer>] Quarters to include (1-4)
80
+ # @return [Hash] Filtered data
81
+ #
82
+ def filter_by_quarters(stock, quarters)
83
+ quarter_months = {
84
+ 1 => [1, 2, 3],
85
+ 2 => [4, 5, 6],
86
+ 3 => [7, 8, 9],
87
+ 4 => [10, 11, 12]
88
+ }
89
+
90
+ months = quarters.flat_map { |q| quarter_months[q] }
91
+ filter_by_months(stock, months)
92
+ end
93
+
94
+ # Detect if stock has seasonal pattern
95
+ #
96
+ # @param monthly_returns [Hash] Monthly return statistics
97
+ # @return [Boolean] True if significant seasonal pattern exists
98
+ #
99
+ def detect_seasonality(monthly_returns)
100
+ returns = monthly_returns.values.map { |stats| stats[:avg_return] }
101
+
102
+ # Check variance in monthly returns
103
+ mean = returns.sum / returns.size
104
+ variance = returns.map { |r| (r - mean)**2 }.sum / returns.size
105
+ std_dev = Math.sqrt(variance)
106
+
107
+ # If standard deviation of monthly returns is high, likely seasonal
108
+ std_dev > 2.0
109
+ end
110
+
111
+ # Get seasonal context for a specific date
112
+ #
113
+ # @param date [Date] Date to check
114
+ # @return [Hash] Seasonal context
115
+ #
116
+ def context_for_date(date)
117
+ {
118
+ month: date.month,
119
+ quarter: ((date.month - 1) / 3) + 1,
120
+ month_name: Date::MONTHNAMES[date.month],
121
+ quarter_name: "Q#{((date.month - 1) / 3) + 1}",
122
+ is_year_end: [11, 12].include?(date.month),
123
+ is_year_start: [1, 2].include?(date.month),
124
+ is_holiday_season: [11, 12].include?(date.month),
125
+ is_earnings_season: [1, 4, 7, 10].include?(date.month)
126
+ }
127
+ end
128
+
129
+ private
130
+
131
+ # Calculate average returns by month
132
+ def calculate_monthly_returns(dates, prices)
133
+ monthly_data = Hash.new { |h, k| h[k] = [] }
134
+
135
+ # Group returns by month
136
+ dates.each_cons(2).with_index do |(d1, d2), i|
137
+ return_pct = ((prices[i + 1] - prices[i]) / prices[i] * 100.0)
138
+ monthly_data[d2.month] << return_pct
139
+ end
140
+
141
+ # Calculate statistics per month
142
+ result = {}
143
+ (1..12).each do |month|
144
+ returns = monthly_data[month]
145
+ if returns.any?
146
+ result[month] = {
147
+ avg_return: returns.sum / returns.size,
148
+ count: returns.size,
149
+ positive_days: returns.count { |r| r > 0 },
150
+ negative_days: returns.count { |r| r < 0 }
151
+ }
152
+ else
153
+ result[month] = {
154
+ avg_return: 0.0,
155
+ count: 0,
156
+ positive_days: 0,
157
+ negative_days: 0
158
+ }
159
+ end
160
+ end
161
+
162
+ result
163
+ end
164
+
165
+ # Calculate average returns by quarter
166
+ def calculate_quarterly_returns(dates, prices)
167
+ quarterly_data = Hash.new { |h, k| h[k] = [] }
168
+
169
+ dates.each_cons(2).with_index do |(d1, d2), i|
170
+ return_pct = ((prices[i + 1] - prices[i]) / prices[i] * 100.0)
171
+ quarter = ((d2.month - 1) / 3) + 1
172
+ quarterly_data[quarter] << return_pct
173
+ end
174
+
175
+ result = {}
176
+ (1..4).each do |quarter|
177
+ returns = quarterly_data[quarter]
178
+ if returns.any?
179
+ result[quarter] = {
180
+ avg_return: returns.sum / returns.size,
181
+ count: returns.size,
182
+ positive_days: returns.count { |r| r > 0 },
183
+ negative_days: returns.count { |r| r < 0 }
184
+ }
185
+ else
186
+ result[quarter] = {
187
+ avg_return: 0.0,
188
+ count: 0,
189
+ positive_days: 0,
190
+ negative_days: 0
191
+ }
192
+ end
193
+ end
194
+
195
+ result
196
+ end
197
+
198
+ # Rank months by performance
199
+ def rank_months(monthly_returns)
200
+ monthly_returns.sort_by { |month, stats| -stats[:avg_return] }.map(&:first)
201
+ end
202
+
203
+ # Rank quarters by performance
204
+ def rank_quarters(quarterly_returns)
205
+ quarterly_returns.sort_by { |quarter, stats| -stats[:avg_return] }.map(&:first)
206
+ end
207
+ end
208
+ end
209
+ end