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,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
|