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
data/lib/sqa/portfolio.rb CHANGED
@@ -1,11 +1,265 @@
1
1
  # lib/sqa/portfolio.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+ require 'csv'
2
6
 
3
7
  class SQA::Portfolio
4
- attr_accessor :df
8
+ attr_accessor :positions, :trades, :cash, :initial_cash
9
+
10
+ # Represents a single position in the portfolio
11
+ Position = Struct.new(:ticker, :shares, :avg_cost, :total_cost) do
12
+ def value(current_price)
13
+ shares * current_price
14
+ end
15
+
16
+ def profit_loss(current_price)
17
+ (current_price - avg_cost) * shares
18
+ end
19
+
20
+ def profit_loss_percent(current_price)
21
+ return 0.0 if avg_cost.zero?
22
+ ((current_price - avg_cost) / avg_cost) * 100.0
23
+ end
24
+ end
25
+
26
+ # Represents a single trade
27
+ Trade = Struct.new(:ticker, :action, :shares, :price, :date, :total, :commission) do
28
+ def to_h
29
+ {
30
+ ticker: ticker,
31
+ action: action,
32
+ shares: shares,
33
+ price: price,
34
+ date: date,
35
+ total: total,
36
+ commission: commission
37
+ }
38
+ end
39
+ end
40
+
41
+ def initialize(initial_cash: 10_000.0, commission: 0.0)
42
+ @initial_cash = initial_cash
43
+ @cash = initial_cash
44
+ @commission = commission # Commission per trade (flat fee or percentage)
45
+ @positions = {} # { ticker => Position }
46
+ @trades = [] # Array of Trade objects
47
+ end
48
+
49
+ # Buy shares of a stock
50
+ # @param ticker [String] Stock ticker symbol
51
+ # @param shares [Integer] Number of shares to buy
52
+ # @param price [Float] Price per share
53
+ # @param date [Date] Date of trade
54
+ # @return [Trade] The executed trade
55
+ def buy(ticker, shares:, price:, date: Date.today)
56
+ raise BadParameterError, "Shares must be positive" if shares <= 0
57
+ raise BadParameterError, "Price must be positive" if price <= 0
58
+
59
+ total_cost = shares * price
60
+ commission = calculate_commission(total_cost)
61
+ total_with_commission = total_cost + commission
62
+
63
+ raise "Insufficient funds: need #{total_with_commission}, have #{@cash}" if total_with_commission > @cash
64
+
65
+ # Update or create position
66
+ if @positions[ticker]
67
+ pos = @positions[ticker]
68
+ total_shares = pos.shares + shares
69
+ total_cost_basis = pos.total_cost + total_cost
70
+ pos.shares = total_shares
71
+ pos.avg_cost = total_cost_basis / total_shares
72
+ pos.total_cost = total_cost_basis
73
+ else
74
+ @positions[ticker] = Position.new(
75
+ ticker,
76
+ shares,
77
+ price,
78
+ total_cost
79
+ )
80
+ end
81
+
82
+ # Deduct cash
83
+ @cash -= total_with_commission
84
+
85
+ # Record trade
86
+ trade = Trade.new(ticker, :buy, shares, price, date, total_cost, commission)
87
+ @trades << trade
88
+
89
+ trade
90
+ end
91
+
92
+ # Sell shares of a stock
93
+ # @param ticker [String] Stock ticker symbol
94
+ # @param shares [Integer] Number of shares to sell
95
+ # @param price [Float] Price per share
96
+ # @param date [Date] Date of trade
97
+ # @return [Trade] The executed trade
98
+ def sell(ticker, shares:, price:, date: Date.today)
99
+ raise BadParameterError, "Shares must be positive" if shares <= 0
100
+ raise BadParameterError, "Price must be positive" if price <= 0
101
+ raise "No position in #{ticker}" unless @positions[ticker]
102
+
103
+ pos = @positions[ticker]
104
+ raise "Insufficient shares: trying to sell #{shares}, have #{pos.shares}" if shares > pos.shares
105
+
106
+ total_sale = shares * price
107
+ commission = calculate_commission(total_sale)
108
+ net_proceeds = total_sale - commission
109
+
110
+ # Update position
111
+ if shares == pos.shares
112
+ # Selling entire position
113
+ @positions.delete(ticker)
114
+ else
115
+ # Partial sale - reduce shares and total cost proportionally
116
+ cost_per_share = pos.total_cost / pos.shares
117
+ pos.shares -= shares
118
+ pos.total_cost -= (cost_per_share * shares)
119
+ # avg_cost stays the same
120
+ end
121
+
122
+ # Add cash
123
+ @cash += net_proceeds
124
+
125
+ # Record trade
126
+ trade = Trade.new(ticker, :sell, shares, price, date, total_sale, commission)
127
+ @trades << trade
128
+
129
+ trade
130
+ end
131
+
132
+ # Get current position for a ticker
133
+ # @param ticker [String] Stock ticker symbol
134
+ # @return [Position, nil] The position or nil if not found
135
+ def position(ticker)
136
+ @positions[ticker]
137
+ end
138
+
139
+ # Get all current positions
140
+ # @return [Hash] Hash of ticker => Position
141
+ def all_positions
142
+ @positions
143
+ end
144
+
145
+ # Calculate total portfolio value
146
+ # @param current_prices [Hash] Hash of ticker => current_price
147
+ # @return [Float] Total portfolio value (cash + positions)
148
+ def value(current_prices = {})
149
+ positions_value = @positions.sum do |ticker, pos|
150
+ current_price = current_prices[ticker] || pos.avg_cost
151
+ pos.value(current_price)
152
+ end
153
+
154
+ @cash + positions_value
155
+ end
156
+
157
+ # Calculate total profit/loss across all positions
158
+ # @param current_prices [Hash] Hash of ticker => current_price
159
+ # @return [Float] Total P&L
160
+ def profit_loss(current_prices = {})
161
+ value(current_prices) - @initial_cash
162
+ end
163
+
164
+ # Calculate profit/loss percentage
165
+ # @param current_prices [Hash] Hash of ticker => current_price
166
+ # @return [Float] P&L percentage
167
+ def profit_loss_percent(current_prices = {})
168
+ return 0.0 if @initial_cash.zero?
169
+ (profit_loss(current_prices) / @initial_cash) * 100.0
170
+ end
171
+
172
+ # Calculate total return (including dividends if tracked)
173
+ # @param current_prices [Hash] Hash of ticker => current_price
174
+ # @return [Float] Total return as decimal (e.g., 0.15 for 15%)
175
+ def total_return(current_prices = {})
176
+ return 0.0 if @initial_cash.zero?
177
+ profit_loss(current_prices) / @initial_cash
178
+ end
179
+
180
+ # Get trade history
181
+ # @return [Array<Trade>] Array of all trades
182
+ def trade_history
183
+ @trades
184
+ end
185
+
186
+ # Get summary statistics
187
+ # @param current_prices [Hash] Hash of ticker => current_price
188
+ # @return [Hash] Summary statistics
189
+ def summary(current_prices = {})
190
+ {
191
+ initial_cash: @initial_cash,
192
+ current_cash: @cash,
193
+ positions_count: @positions.size,
194
+ total_value: value(current_prices),
195
+ profit_loss: profit_loss(current_prices),
196
+ profit_loss_percent: profit_loss_percent(current_prices),
197
+ total_return: total_return(current_prices),
198
+ total_trades: @trades.size,
199
+ buy_trades: @trades.count { |t| t.action == :buy },
200
+ sell_trades: @trades.count { |t| t.action == :sell }
201
+ }
202
+ end
203
+
204
+ # Save portfolio to CSV file
205
+ # @param filename [String] Path to CSV file
206
+ def save_to_csv(filename)
207
+ CSV.open(filename, 'wb') do |csv|
208
+ csv << ['ticker', 'shares', 'avg_cost', 'total_cost']
209
+ @positions.each do |ticker, pos|
210
+ csv << [ticker, pos.shares, pos.avg_cost, pos.total_cost]
211
+ end
212
+ end
213
+ end
214
+
215
+ # Save trade history to CSV file
216
+ # @param filename [String] Path to CSV file
217
+ def save_trades_to_csv(filename)
218
+ CSV.open(filename, 'wb') do |csv|
219
+ csv << ['date', 'ticker', 'action', 'shares', 'price', 'total', 'commission']
220
+ @trades.each do |trade|
221
+ csv << [
222
+ trade.date,
223
+ trade.ticker,
224
+ trade.action,
225
+ trade.shares,
226
+ trade.price,
227
+ trade.total,
228
+ trade.commission
229
+ ]
230
+ end
231
+ end
232
+ end
233
+
234
+ # Load portfolio from CSV file
235
+ # @param filename [String] Path to CSV file
236
+ def self.load_from_csv(filename)
237
+ portfolio = new(initial_cash: 0)
238
+
239
+ CSV.foreach(filename, headers: true) do |row|
240
+ ticker = row['ticker']
241
+ shares = row['shares'].to_i
242
+ avg_cost = row['avg_cost'].to_f
243
+ total_cost = row['total_cost'].to_f
244
+
245
+ portfolio.positions[ticker] = Position.new(
246
+ ticker,
247
+ shares,
248
+ avg_cost,
249
+ total_cost
250
+ )
251
+ end
252
+
253
+ portfolio
254
+ end
255
+
256
+ private
5
257
 
6
- def initialize(
7
- filename = SQA::Config.portfolio_filename
8
- )
9
- @df = SQA::DataFrame.load(filename)
10
- end
258
+ # Calculate commission for a trade
259
+ # @param total [Float] Total trade value
260
+ # @return [Float] Commission amount
261
+ def calculate_commission(total)
262
+ # Simple flat commission model
263
+ @commission
264
+ end
11
265
  end
@@ -0,0 +1,377 @@
1
+ # lib/sqa/portfolio_optimizer.rb
2
+ # frozen_string_literal: true
3
+
4
+ module SQA
5
+ ##
6
+ # PortfolioOptimizer - Multi-objective portfolio optimization
7
+ #
8
+ # Provides methods for:
9
+ # - Mean-Variance Optimization (Markowitz)
10
+ # - Multi-objective optimization (return vs risk vs drawdown)
11
+ # - Efficient Frontier calculation
12
+ # - Risk Parity allocation
13
+ # - Minimum Variance portfolio
14
+ # - Maximum Sharpe portfolio
15
+ #
16
+ # @example Find optimal portfolio weights
17
+ # returns_matrix = [
18
+ # [0.01, -0.02, 0.015], # Stock 1 returns
19
+ # [0.02, 0.01, -0.01], # Stock 2 returns
20
+ # [-0.01, 0.03, 0.02] # Stock 3 returns
21
+ # ]
22
+ # weights = SQA::PortfolioOptimizer.maximum_sharpe(returns_matrix)
23
+ # # => [0.4, 0.3, 0.3]
24
+ #
25
+ class PortfolioOptimizer
26
+ class << self
27
+ ##
28
+ # Calculate portfolio returns given weights
29
+ #
30
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset (rows = assets, cols = periods)
31
+ # @param weights [Array<Float>] Portfolio weights (must sum to 1.0)
32
+ # @return [Array<Float>] Portfolio returns over time
33
+ #
34
+ def portfolio_returns(returns_matrix, weights)
35
+ num_periods = returns_matrix.first.size
36
+
37
+ num_periods.times.map do |period_idx|
38
+ returns_matrix.each_with_index.sum do |asset_returns, asset_idx|
39
+ asset_returns[period_idx] * weights[asset_idx]
40
+ end
41
+ end
42
+ end
43
+
44
+ ##
45
+ # Calculate portfolio variance
46
+ #
47
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset
48
+ # @param weights [Array<Float>] Portfolio weights
49
+ # @return [Float] Portfolio variance
50
+ #
51
+ def portfolio_variance(returns_matrix, weights)
52
+ covariance_matrix = calculate_covariance_matrix(returns_matrix)
53
+
54
+ # Portfolio variance = w^T * Σ * w
55
+ variance = 0.0
56
+ weights.each_with_index do |wi, i|
57
+ weights.each_with_index do |wj, j|
58
+ variance += wi * wj * covariance_matrix[i][j]
59
+ end
60
+ end
61
+
62
+ variance
63
+ end
64
+
65
+ ##
66
+ # Find Maximum Sharpe Ratio portfolio
67
+ #
68
+ # Uses numerical optimization to find weights that maximize Sharpe ratio.
69
+ #
70
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset
71
+ # @param risk_free_rate [Float] Risk-free rate (default: 0.02)
72
+ # @param constraints [Hash] Optimization constraints
73
+ # @return [Hash] { weights: Array, sharpe: Float, return: Float, volatility: Float }
74
+ #
75
+ def maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {})
76
+ num_assets = returns_matrix.size
77
+
78
+ # Grid search optimization (simple but effective)
79
+ best_sharpe = -Float::INFINITY
80
+ best_weights = nil
81
+
82
+ # Try random portfolios
83
+ 10_000.times do
84
+ weights = random_weights(num_assets, constraints)
85
+
86
+ port_returns = portfolio_returns(returns_matrix, weights)
87
+ sharpe = SQA::RiskManager.sharpe_ratio(port_returns, risk_free_rate: risk_free_rate)
88
+
89
+ if sharpe > best_sharpe
90
+ best_sharpe = sharpe
91
+ best_weights = weights
92
+ end
93
+ end
94
+
95
+ port_returns = portfolio_returns(returns_matrix, best_weights)
96
+ mean_return = port_returns.sum / port_returns.size.to_f
97
+ volatility = Math.sqrt(portfolio_variance(returns_matrix, best_weights))
98
+
99
+ {
100
+ weights: best_weights,
101
+ sharpe: best_sharpe,
102
+ return: mean_return * 252, # Annualized
103
+ volatility: volatility * Math.sqrt(252) # Annualized
104
+ }
105
+ end
106
+
107
+ ##
108
+ # Find Minimum Variance portfolio
109
+ #
110
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset
111
+ # @param constraints [Hash] Optimization constraints
112
+ # @return [Hash] { weights: Array, variance: Float, volatility: Float }
113
+ #
114
+ def minimum_variance(returns_matrix, constraints: {})
115
+ num_assets = returns_matrix.size
116
+
117
+ best_variance = Float::INFINITY
118
+ best_weights = nil
119
+
120
+ # Grid search
121
+ 10_000.times do
122
+ weights = random_weights(num_assets, constraints)
123
+ variance = portfolio_variance(returns_matrix, weights)
124
+
125
+ if variance < best_variance
126
+ best_variance = variance
127
+ best_weights = weights
128
+ end
129
+ end
130
+
131
+ {
132
+ weights: best_weights,
133
+ variance: best_variance,
134
+ volatility: Math.sqrt(best_variance) * Math.sqrt(252) # Annualized
135
+ }
136
+ end
137
+
138
+ ##
139
+ # Calculate Risk Parity portfolio
140
+ #
141
+ # Allocate weights so each asset contributes equally to portfolio risk.
142
+ #
143
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset
144
+ # @return [Hash] { weights: Array, volatility: Float }
145
+ #
146
+ def risk_parity(returns_matrix)
147
+ # Calculate individual volatilities
148
+ volatilities = returns_matrix.map do |asset_returns|
149
+ mean = asset_returns.sum / asset_returns.size.to_f
150
+ variance = asset_returns.map { |r| (r - mean)**2 }.sum / asset_returns.size.to_f
151
+ Math.sqrt(variance)
152
+ end
153
+
154
+ # Inverse volatility weighting (approximation of risk parity)
155
+ inv_vols = volatilities.map { |v| 1.0 / v }
156
+ sum_inv_vols = inv_vols.sum
157
+
158
+ weights = inv_vols.map { |iv| iv / sum_inv_vols }
159
+
160
+ {
161
+ weights: weights,
162
+ volatility: Math.sqrt(portfolio_variance(returns_matrix, weights)) * Math.sqrt(252)
163
+ }
164
+ end
165
+
166
+ ##
167
+ # Calculate Efficient Frontier
168
+ #
169
+ # Generate portfolios along the efficient frontier.
170
+ #
171
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset
172
+ # @param num_portfolios [Integer] Number of portfolios to generate
173
+ # @return [Array<Hash>] Array of portfolio hashes
174
+ #
175
+ def efficient_frontier(returns_matrix, num_portfolios: 50)
176
+ portfolios = []
177
+
178
+ num_portfolios.times do
179
+ weights = random_weights(returns_matrix.size, {})
180
+
181
+ port_returns = portfolio_returns(returns_matrix, weights)
182
+ mean_return = port_returns.sum / port_returns.size.to_f
183
+ variance = portfolio_variance(returns_matrix, weights)
184
+ volatility = Math.sqrt(variance)
185
+
186
+ portfolios << {
187
+ weights: weights,
188
+ return: mean_return * 252,
189
+ volatility: volatility * Math.sqrt(252),
190
+ sharpe: SQA::RiskManager.sharpe_ratio(port_returns)
191
+ }
192
+ end
193
+
194
+ # Sort by volatility
195
+ portfolios.sort_by { |p| p[:volatility] }
196
+ end
197
+
198
+ ##
199
+ # Multi-objective optimization
200
+ #
201
+ # Optimize portfolio for multiple objectives simultaneously.
202
+ #
203
+ # @param returns_matrix [Array<Array<Float>>] Returns for each asset
204
+ # @param objectives [Hash] Objectives with weights
205
+ # @return [Hash] Optimal portfolio
206
+ #
207
+ # @example
208
+ # result = SQA::PortfolioOptimizer.multi_objective(
209
+ # returns_matrix,
210
+ # objectives: {
211
+ # maximize_return: 0.4,
212
+ # minimize_volatility: 0.3,
213
+ # minimize_drawdown: 0.3
214
+ # }
215
+ # )
216
+ #
217
+ def multi_objective(returns_matrix, objectives: {})
218
+ num_assets = returns_matrix.size
219
+
220
+ best_score = -Float::INFINITY
221
+ best_portfolio = nil
222
+
223
+ # Default objectives
224
+ objectives = {
225
+ maximize_return: 0.33,
226
+ minimize_volatility: 0.33,
227
+ minimize_drawdown: 0.34
228
+ } if objectives.empty?
229
+
230
+ # Normalize objective weights
231
+ total_weight = objectives.values.sum
232
+ objectives = objectives.transform_values { |v| v / total_weight }
233
+
234
+ # Grid search
235
+ 10_000.times do
236
+ weights = random_weights(num_assets, {})
237
+
238
+ port_returns = portfolio_returns(returns_matrix, weights)
239
+ mean_return = port_returns.sum / port_returns.size.to_f
240
+ variance = portfolio_variance(returns_matrix, weights)
241
+ volatility = Math.sqrt(variance)
242
+
243
+ # Convert to prices for drawdown
244
+ prices = port_returns.inject([100.0]) { |acc, r| acc << acc.last * (1 + r) }
245
+ max_dd = SQA::RiskManager.max_drawdown(prices)[:max_drawdown].abs
246
+
247
+ # Calculate composite score
248
+ score = 0.0
249
+
250
+ # Normalize and combine objectives
251
+ if objectives[:maximize_return]
252
+ score += (mean_return * 252) * objectives[:maximize_return] * 10 # Scale up
253
+ end
254
+
255
+ if objectives[:minimize_volatility]
256
+ score -= (volatility * Math.sqrt(252)) * objectives[:minimize_volatility] * 10
257
+ end
258
+
259
+ if objectives[:minimize_drawdown]
260
+ score -= max_dd * objectives[:minimize_drawdown] * 10
261
+ end
262
+
263
+ if score > best_score
264
+ best_score = score
265
+ best_portfolio = {
266
+ weights: weights,
267
+ return: mean_return * 252,
268
+ volatility: volatility * Math.sqrt(252),
269
+ max_drawdown: max_dd,
270
+ sharpe: SQA::RiskManager.sharpe_ratio(port_returns),
271
+ composite_score: score
272
+ }
273
+ end
274
+ end
275
+
276
+ best_portfolio
277
+ end
278
+
279
+ ##
280
+ # Equal weight portfolio (1/N rule)
281
+ #
282
+ # @param num_assets [Integer] Number of assets
283
+ # @return [Array<Float>] Equal weights
284
+ #
285
+ def equal_weight(num_assets)
286
+ weight = 1.0 / num_assets
287
+ Array.new(num_assets, weight)
288
+ end
289
+
290
+ ##
291
+ # Rebalance portfolio to target weights
292
+ #
293
+ # @param current_values [Hash] Current holdings { ticker => value }
294
+ # @param target_weights [Hash] Target weights { ticker => weight }
295
+ # @param total_value [Float] Total portfolio value
296
+ # @return [Hash] Rebalancing trades { ticker => { action: :buy/:sell, shares: N, value: $ } }
297
+ #
298
+ def rebalance(current_values:, target_weights:, total_value:, prices:)
299
+ trades = {}
300
+
301
+ target_weights.each do |ticker, target_weight|
302
+ current_value = current_values[ticker] || 0.0
303
+ target_value = total_value * target_weight
304
+ difference = target_value - current_value
305
+
306
+ next if difference.abs < 1.0 # Skip tiny adjustments
307
+
308
+ price = prices[ticker]
309
+ next if price.nil? || price.zero?
310
+
311
+ shares = (difference / price).round
312
+
313
+ trades[ticker] = {
314
+ action: shares > 0 ? :buy : :sell,
315
+ shares: shares.abs,
316
+ value: shares * price,
317
+ current_weight: current_value / total_value,
318
+ target_weight: target_weight
319
+ }
320
+ end
321
+
322
+ trades
323
+ end
324
+
325
+ private
326
+
327
+ ##
328
+ # Calculate covariance matrix
329
+ def calculate_covariance_matrix(returns_matrix)
330
+ num_assets = returns_matrix.size
331
+ num_periods = returns_matrix.first.size
332
+
333
+ # Calculate means
334
+ means = returns_matrix.map do |returns|
335
+ returns.sum / returns.size.to_f
336
+ end
337
+
338
+ # Calculate covariance
339
+ covariance = Array.new(num_assets) { Array.new(num_assets, 0.0) }
340
+
341
+ num_assets.times do |i|
342
+ num_assets.times do |j|
343
+ cov = 0.0
344
+ num_periods.times do |t|
345
+ cov += (returns_matrix[i][t] - means[i]) * (returns_matrix[j][t] - means[j])
346
+ end
347
+ covariance[i][j] = cov / (num_periods - 1).to_f
348
+ end
349
+ end
350
+
351
+ covariance
352
+ end
353
+
354
+ ##
355
+ # Generate random portfolio weights
356
+ def random_weights(num_assets, constraints)
357
+ # Generate random weights that sum to 1.0
358
+ weights = num_assets.times.map { rand }
359
+ sum = weights.sum
360
+ weights = weights.map { |w| w / sum }
361
+
362
+ # Apply constraints
363
+ min_weight = constraints[:min_weight] || 0.0
364
+ max_weight = constraints[:max_weight] || 1.0
365
+
366
+ # Adjust if constraints violated
367
+ weights = weights.map do |w|
368
+ [[w, min_weight].max, max_weight].min
369
+ end
370
+
371
+ # Renormalize
372
+ sum = weights.sum
373
+ weights.map { |w| w / sum }
374
+ end
375
+ end
376
+ end
377
+ end