Class: SQA::PortfolioOptimizer
- Inherits:
-
Object
- Object
- SQA::PortfolioOptimizer
- Defined in:
- lib/sqa/portfolio_optimizer.rb
Overview
PortfolioOptimizer - Multi-objective portfolio optimization
Provides methods for:
-
Mean-Variance Optimization (Markowitz)
-
Multi-objective optimization (return vs risk vs drawdown)
-
Efficient Frontier calculation
-
Risk Parity allocation
-
Minimum Variance portfolio
-
Maximum Sharpe portfolio
Class Method Summary collapse
-
.efficient_frontier(returns_matrix, num_portfolios: 50) ⇒ Array<Hash>
Calculate Efficient Frontier.
-
.equal_weight(num_assets) ⇒ Array<Float>
Equal weight portfolio (1/N rule).
-
.maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {}) ⇒ Hash
Find Maximum Sharpe Ratio portfolio.
-
.minimum_variance(returns_matrix, constraints: {}) ⇒ Hash
Find Minimum Variance portfolio.
-
.multi_objective(returns_matrix, objectives: {}) ⇒ Hash
Multi-objective optimization.
-
.portfolio_returns(returns_matrix, weights) ⇒ Array<Float>
Calculate portfolio returns given weights.
-
.portfolio_variance(returns_matrix, weights) ⇒ Float
Calculate portfolio variance.
-
.rebalance(current_values:, target_weights:, total_value:, prices:) ⇒ Hash
Rebalance portfolio to target weights.
-
.risk_parity(returns_matrix) ⇒ Hash
Calculate Risk Parity portfolio.
Class Method Details
.efficient_frontier(returns_matrix, num_portfolios: 50) ⇒ Array<Hash>
Calculate Efficient Frontier
Generate portfolios along the efficient frontier.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/sqa/portfolio_optimizer.rb', line 175 def efficient_frontier(returns_matrix, num_portfolios: 50) portfolios = [] num_portfolios.times do weights = random_weights(returns_matrix.size, {}) port_returns = portfolio_returns(returns_matrix, weights) mean_return = port_returns.sum / port_returns.size.to_f variance = portfolio_variance(returns_matrix, weights) volatility = Math.sqrt(variance) portfolios << { weights: weights, return: mean_return * 252, volatility: volatility * Math.sqrt(252), sharpe: SQA::RiskManager.sharpe_ratio(port_returns) } end # Sort by volatility portfolios.sort_by { |p| p[:volatility] } end |
.equal_weight(num_assets) ⇒ Array<Float>
Equal weight portfolio (1/N rule)
285 286 287 288 |
# File 'lib/sqa/portfolio_optimizer.rb', line 285 def equal_weight(num_assets) weight = 1.0 / num_assets Array.new(num_assets, weight) end |
.maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {}) ⇒ Hash
Find Maximum Sharpe Ratio portfolio
Uses numerical optimization to find weights that maximize Sharpe ratio.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/sqa/portfolio_optimizer.rb', line 75 def maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {}) num_assets = returns_matrix.size # Grid search optimization (simple but effective) best_sharpe = -Float::INFINITY best_weights = nil # Try random portfolios 10_000.times do weights = random_weights(num_assets, constraints) port_returns = portfolio_returns(returns_matrix, weights) sharpe = SQA::RiskManager.sharpe_ratio(port_returns, risk_free_rate: risk_free_rate) if sharpe > best_sharpe best_sharpe = sharpe best_weights = weights end end port_returns = portfolio_returns(returns_matrix, best_weights) mean_return = port_returns.sum / port_returns.size.to_f volatility = Math.sqrt(portfolio_variance(returns_matrix, best_weights)) { weights: best_weights, sharpe: best_sharpe, return: mean_return * 252, # Annualized volatility: volatility * Math.sqrt(252) # Annualized } end |
.minimum_variance(returns_matrix, constraints: {}) ⇒ Hash
Find Minimum Variance portfolio
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/sqa/portfolio_optimizer.rb', line 114 def minimum_variance(returns_matrix, constraints: {}) num_assets = returns_matrix.size best_variance = Float::INFINITY best_weights = nil # Grid search 10_000.times do weights = random_weights(num_assets, constraints) variance = portfolio_variance(returns_matrix, weights) if variance < best_variance best_variance = variance best_weights = weights end end { weights: best_weights, variance: best_variance, volatility: Math.sqrt(best_variance) * Math.sqrt(252) # Annualized } end |
.multi_objective(returns_matrix, objectives: {}) ⇒ Hash
Multi-objective optimization
Optimize portfolio for multiple objectives simultaneously.
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/sqa/portfolio_optimizer.rb', line 217 def multi_objective(returns_matrix, objectives: {}) num_assets = returns_matrix.size best_score = -Float::INFINITY best_portfolio = nil # Default objectives objectives = { maximize_return: 0.33, minimize_volatility: 0.33, minimize_drawdown: 0.34 } if objectives.empty? # Normalize objective weights total_weight = objectives.values.sum objectives = objectives.transform_values { |v| v / total_weight } # Grid search 10_000.times do weights = random_weights(num_assets, {}) port_returns = portfolio_returns(returns_matrix, weights) mean_return = port_returns.sum / port_returns.size.to_f variance = portfolio_variance(returns_matrix, weights) volatility = Math.sqrt(variance) # Convert to prices for drawdown prices = port_returns.inject([100.0]) { |acc, r| acc << acc.last * (1 + r) } max_dd = SQA::RiskManager.max_drawdown(prices)[:max_drawdown].abs # Calculate composite score score = 0.0 # Normalize and combine objectives if objectives[:maximize_return] score += (mean_return * 252) * objectives[:maximize_return] * 10 # Scale up end if objectives[:minimize_volatility] score -= (volatility * Math.sqrt(252)) * objectives[:minimize_volatility] * 10 end if objectives[:minimize_drawdown] score -= max_dd * objectives[:minimize_drawdown] * 10 end if score > best_score best_score = score best_portfolio = { weights: weights, return: mean_return * 252, volatility: volatility * Math.sqrt(252), max_drawdown: max_dd, sharpe: SQA::RiskManager.sharpe_ratio(port_returns), composite_score: score } end end best_portfolio end |
.portfolio_returns(returns_matrix, weights) ⇒ Array<Float>
Calculate portfolio returns given weights
34 35 36 37 38 39 40 41 42 |
# File 'lib/sqa/portfolio_optimizer.rb', line 34 def portfolio_returns(returns_matrix, weights) num_periods = returns_matrix.first.size num_periods.times.map do |period_idx| returns_matrix.each_with_index.sum do |asset_returns, asset_idx| asset_returns[period_idx] * weights[asset_idx] end end end |
.portfolio_variance(returns_matrix, weights) ⇒ Float
Calculate portfolio variance
51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/sqa/portfolio_optimizer.rb', line 51 def portfolio_variance(returns_matrix, weights) covariance_matrix = calculate_covariance_matrix(returns_matrix) # Portfolio variance = w^T * Σ * w variance = 0.0 weights.each_with_index do |wi, i| weights.each_with_index do |wj, j| variance += wi * wj * covariance_matrix[i][j] end end variance end |
.rebalance(current_values:, target_weights:, total_value:, prices:) ⇒ Hash
Rebalance portfolio to target weights
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'lib/sqa/portfolio_optimizer.rb', line 298 def rebalance(current_values:, target_weights:, total_value:, prices:) trades = {} target_weights.each do |ticker, target_weight| current_value = current_values[ticker] || 0.0 target_value = total_value * target_weight difference = target_value - current_value next if difference.abs < 1.0 # Skip tiny adjustments price = prices[ticker] next if price.nil? || price.zero? shares = (difference / price).round trades[ticker] = { action: shares > 0 ? :buy : :sell, shares: shares.abs, value: shares * price, current_weight: current_value / total_value, target_weight: target_weight } end trades end |
.risk_parity(returns_matrix) ⇒ Hash
Calculate Risk Parity portfolio
Allocate weights so each asset contributes equally to portfolio risk.
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/sqa/portfolio_optimizer.rb', line 146 def risk_parity(returns_matrix) # Calculate individual volatilities volatilities = returns_matrix.map do |asset_returns| mean = asset_returns.sum / asset_returns.size.to_f variance = asset_returns.map { |r| (r - mean)**2 }.sum / asset_returns.size.to_f Math.sqrt(variance) end # Inverse volatility weighting (approximation of risk parity) inv_vols = volatilities.map { |v| 1.0 / v } sum_inv_vols = inv_vols.sum weights = inv_vols.map { |iv| iv / sum_inv_vols } { weights: weights, volatility: Math.sqrt(portfolio_variance(returns_matrix, weights)) * Math.sqrt(252) } end |